From a429897b791b08703a99f2f4ae87623386279772 Mon Sep 17 00:00:00 2001 From: Amos Date: Wed, 13 May 2026 18:01:32 +0100 Subject: [PATCH 1/9] v2: unify PLUTAINER_* env vars, restructure volume layout, harden entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Volume layout (breaking — v1 volumes refused on start, see migrate-v1-to-v2.sh): app/configs/ user-facing real *.cfg files (flat); SOT app/logs/ stable symlinks (unchanged) app/runtime/gamefiles/, app/runtime/plutonium/ app/.plutainer-version=2 Each entry script fans out symlinks from app/configs/*.cfg into the engine's expected config dir, so games read configs through symlinks back to the user-facing dir. RCON writeconfig writes back transparently. Env vars unified: PLUTAINER_GAME/CONFIG_FILE/PORT/SERVER_NAME/MOD/AUTO_UPDATE/ HEALTHCHECK/SKIP_SEED/EXTRA_ARGS. Old PLUTO_/IW4X_/ALTER_ prefixes still work via shim_env_vars in game-config.sh, emit [DEPRECATED] warning. PLUTO_SERVER_KEY, PLUTO_MAX_CLIENTS, IW4X_NET_LOG_IP keep original names (single-family vars). Other fixes folded in: - Symlink existence guards via new link_files helper (eliminates dangling symlinks when host gamefiles are missing — same class as the prior t7x '*' bug). - set -euo pipefail in all entry scripts. - wget -N for t7x.exe (skip redownload when unchanged). - HEALTHCHECK start-period: 1m -> 5m for slow first-run downloads. - migrate-v1-to-v2.sh handles the volume restructure (supports --dry-run). - .gitattributes pins shell/python/Dockerfile to LF. --- .gitattributes | 5 + CLAUDE.md | 84 +++++++---- Dockerfile | 5 +- EXAMPLE-docker-compose.yml | 114 +++++++-------- README.md | 206 +++++++++++++++++--------- scripts/alterentry.sh | 112 +++++++-------- scripts/entrypoint.sh | 50 +++++-- scripts/game-config.sh | 278 +++++++++++++++++++++++++++++------- scripts/iw4xentry.sh | 104 +++++++------- scripts/migrate-v1-to-v2.sh | 159 +++++++++++++++++++++ scripts/plutoentry.sh | 141 +++++++++--------- 11 files changed, 864 insertions(+), 394 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/migrate-v1-to-v2.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e6791bc --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index dc92dce..0dc7ffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ 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 @@ -12,14 +12,14 @@ Plutainer is a Docker image for running Plutonium, IW4x, and Alterware dedicated # Build the Docker image locally docker build -t plutainer . -# Run a Plutonium container example -docker run -e PLUTO_GAME=t6zm -e PLUTO_SERVER_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= -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 @@ -27,40 +27,76 @@ docker run -e IW4X_GAME=iw4x -e IW4X_CONFIG_FILE=server.cfg \ 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:main` — current v2 layout. Use this for new deployments. +- `ghcr.io/ayymoss/plutainer:latest` — deprecated v1 layout. No further updates. + ## 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`, applies `shim_env_vars` (back-compat for old prefixed names), calls `detect_game_type` to derive `GAME_TYPE`/`BASE_GAME`/etc from `PLUTAINER_GAME`, runs `check_volume_version` (refuses v1 volumes), then `exec`s the family-specific entry script. -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. +2. **`plutoentry.sh`** — Plutonium server entrypoint. Symlinks game files from the read-only gamefiles mount, runs `plutonium-updater`, seeds bundled configs into `app/configs/`, fans out symlinks from `app/configs/` to the engine's expected config dir, validates env vars, then `exec`s `wine bin/plutonium-bootstrapper-win32.exe`. -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`. +3. **`iw4xentry.sh`** — IW4x server entrypoint. Same shape: symlinks game files, runs `iw4x-launcher`, fans out config symlinks, validates env vars, `exec`s `wine iw4x.exe`. No seed bundle (IW4x has no community config repo configured). -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`. +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, validates env vars, starts `Xvfb` (T7x requires a display), `exec`s `wine t7x.exe`. -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`. +5. **`game-config.sh`** — Shared shell library sourced by all other scripts. Provides: + - Volume path constants: `PLUTAINER_APP_DIR`, `PLUTAINER_CONFIGS_DIR`, `PLUTAINER_RUNTIME_DIR`, `PLUTAINER_GAMEFILES_DIR`, `PLUTAINER_PLUTONIUM_DIR`, `PLUTAINER_SOURCE_DIR`. + - `shim_env_vars`: maps deprecated `PLUTO_*`/`IW4X_*`/`ALTER_*` names onto unified `PLUTAINER_*` with a `[DEPRECATED]` warning. + - `derive_family `: returns `plutonium`/`iw4x`/`alterware`. + - `detect_game_type`: validates `PLUTAINER_GAME`, sets `GAME_TYPE`/`GAME_NAME`/`BASE_GAME`/`CONFIG_FILE`/`CUSTOM_PORT`/`HEALTHCHECK_FLAG`. + - `resolve_default_port`, `resolve_engine_config_dir`, `resolve_config_path`. + - `link_files ...`: existence-guarded symlink helper; replaces unsafe `ln -sf src/{a,b,c} dest/` bash brace expansion that creates dangling symlinks when source files are missing. + - `seed_configs `: walks bundled seed, lifts top-level `*.cfg` files inside `cfg-root-rel` into `app/configs/`, places everything else under `asset-root`. Idempotent (`cp -n`). + - `link_configs `: fans out symlinks from every `app/configs/*.cfg` into the engine's expected dir using relative paths. Reaps dangling cfg symlinks. + - `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`. -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. +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`. -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/` 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). +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/` 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). -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`. +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. -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. +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. -9. **`pyquake3.py`** — Python 3 Quake 3 protocol library (UDP). Used by the health check and `rcon-cli` for RCON queries. +10. **`pyquake3.py`** — Python 3 Quake 3 protocol library (UDP). Used by the health check and `rcon-cli` for RCON queries. + +## Volume Layout (v2) + +``` +/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. +``` + +**Config flow:** user edits `app/configs/.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//`. +- **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). -- **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) +## Backward Compatibility -## Container Layout +Old prefixed env vars (`PLUTO_*`, `IW4X_*`, `ALTER_*`) still work — `shim_env_vars` in `game-config.sh` copies them onto the canonical `PLUTAINER_*` names and emits a `[DEPRECATED]` warning. Three names stay as-is because they only apply to a single engine family: `PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, `IW4X_NET_LOG_IP`. -- `/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 +A v1 `app/` volume is refused on startup (`check_volume_version`) with explicit instructions to run `migrate-v1-to-v2.sh`. diff --git a/Dockerfile b/Dockerfile index 4a918e0..262c920 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,7 +75,8 @@ 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 @@ -83,7 +84,7 @@ 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"] diff --git a/EXAMPLE-docker-compose.yml b/EXAMPLE-docker-compose.yml index 104d1f2..be2990c 100644 --- a/EXAMPLE-docker-compose.yml +++ b/EXAMPLE-docker-compose.yml @@ -1,24 +1,24 @@ services: iw4xmp-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: iw4xmp-1 restart: unless-stopped ports: - "28970:28970/udp" #- "28970:28970/tcp" # TCP is needed for modlist metadata (not required if not hosting mods) volumes: - - /opt/GameFiles/IW4xServerFiles:/home/plutainer/gamefiles + - /opt/GameFiles/IW4xServerFiles:/home/plutainer/gamefiles:ro - ./iw4xmp-1:/home/plutainer/app environment: - - IW4X_PORT=28970 - - IW4X_CONFIG_FILE=server.cfg - - IW4X_GAME=iw4x - - IW4X_AUTO_UPDATE=false + - PLUTAINER_GAME=iw4x + - PLUTAINER_CONFIG_FILE=server.cfg + - PLUTAINER_PORT=28970 + - PLUTAINER_AUTO_UPDATE=false networks: - games-net t6zm-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: t6zm-1 hostname: t6zm-1 restart: unless-stopped @@ -28,15 +28,15 @@ services: - /opt/GameFiles/T6ServerFiles:/home/plutainer/gamefiles:ro - ./t6zm-1:/home/plutainer/app environment: - - PLUTO_PORT=4976 + - PLUTAINER_GAME=t6zm + - PLUTAINER_CONFIG_FILE=dedicated_zm.cfg + - PLUTAINER_PORT=4976 - PLUTO_SERVER_KEY=${T6ZM_PLUTO_SERVER_KEY} - - PLUTO_CONFIG_FILE=dedicated_zm.cfg - - PLUTO_GAME=t6zm networks: - games-net t6mp-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: t6mp-1 hostname: t6mp-1 restart: unless-stopped @@ -46,15 +46,15 @@ services: - /opt/GameFiles/T6ServerFiles:/home/plutainer/gamefiles:ro - ./t6mp-1:/home/plutainer/app environment: - - PLUTO_PORT=4977 + - PLUTAINER_GAME=t6mp + - PLUTAINER_CONFIG_FILE=dedicated.cfg + - PLUTAINER_PORT=4977 - PLUTO_SERVER_KEY=${T6MP_PLUTO_SERVER_KEY} - - PLUTO_CONFIG_FILE=dedicated.cfg - - PLUTO_GAME=t6mp networks: - games-net - + t4sp-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: t4sp-1 hostname: t4sp-1 restart: unless-stopped @@ -64,15 +64,15 @@ services: - /opt/GameFiles/T4ServerFiles:/home/plutainer/gamefiles:ro - ./t4sp-1:/home/plutainer/app environment: - - PLUTO_PORT=28960 + - PLUTAINER_GAME=t4sp + - PLUTAINER_CONFIG_FILE=server_zm.cfg + - PLUTAINER_PORT=28960 - PLUTO_SERVER_KEY=${T4SP_PLUTO_SERVER_KEY} - - PLUTO_CONFIG_FILE=server_zm.cfg - - PLUTO_GAME=t4sp networks: - games-net - + t4mp-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: t4mp-1 hostname: t4mp-1 restart: unless-stopped @@ -82,15 +82,15 @@ services: - /opt/GameFiles/T4ServerFiles:/home/plutainer/gamefiles:ro - ./t4mp-1:/home/plutainer/app environment: - - PLUTO_PORT=28961 + - PLUTAINER_GAME=t4mp + - PLUTAINER_CONFIG_FILE=server.cfg + - PLUTAINER_PORT=28961 - PLUTO_SERVER_KEY=${T4MP_PLUTO_SERVER_KEY} - - PLUTO_CONFIG_FILE=server.cfg - - PLUTO_GAME=t4mp networks: - games-net - + t5mp-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: t5mp-1 hostname: t5mp-1 restart: unless-stopped @@ -100,16 +100,16 @@ services: - /opt/GameFiles/T5ServerFiles:/home/plutainer/gamefiles:ro - ./t5mp-1:/home/plutainer/app environment: - - PLUTO_PORT=28962 + - PLUTAINER_GAME=t5mp + - PLUTAINER_CONFIG_FILE=dedicated.cfg + - PLUTAINER_PORT=28962 - PLUTO_SERVER_KEY=${T5MP_PLUTO_SERVER_KEY} - - PLUTO_CONFIG_FILE=dedicated.cfg - - PLUTO_GAME=t5mp - PLUTO_MAX_CLIENTS=18 networks: - games-net - + t5sp-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: t5sp-1 hostname: t5sp-1 restart: unless-stopped @@ -119,17 +119,17 @@ services: - /opt/GameFiles/T5ServerFiles:/home/plutainer/gamefiles:ro - ./t5sp-1:/home/plutainer/app environment: - - PLUTO_PORT=28963 + - PLUTAINER_GAME=t5sp + - PLUTAINER_CONFIG_FILE=dedicated_sp.cfg + - PLUTAINER_PORT=28963 + - PLUTAINER_MOD=mods/Stairway_to_hell_bo1 - PLUTO_SERVER_KEY=${T5SP_PLUTO_SERVER_KEY} - - PLUTO_CONFIG_FILE=dedicated_sp.cfg - - PLUTO_GAME=t5sp - - PLUTO_MOD=mods/Stairway_to_hell_bo1 - PLUTO_MAX_CLIENTS=4 networks: - games-net - + iw5mp-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: iw5mp-1 hostname: iw5mp-1 restart: unless-stopped @@ -139,15 +139,15 @@ services: - /opt/GameFiles/IW5ServerFiles:/home/plutainer/gamefiles:ro - ./iw5mp-1:/home/plutainer/app environment: - - PLUTO_PORT=27016 + - PLUTAINER_GAME=iw5mp + - PLUTAINER_CONFIG_FILE=server.cfg + - PLUTAINER_PORT=27016 - PLUTO_SERVER_KEY=${IW5MP_PLUTO_SERVER_KEY} - - PLUTO_CONFIG_FILE=server.cfg - - PLUTO_GAME=iw5mp networks: - games-net - + t7x-zm-1: - image: ghcr.io/ayymoss/plutainer:latest + image: ghcr.io/ayymoss/plutainer:main container_name: t7x-zm-1 restart: unless-stopped ports: @@ -156,11 +156,11 @@ services: - /opt/GameFiles/T7xServerFiles:/home/plutainer/gamefiles:ro - ./t7x-zm-1:/home/plutainer/app environment: - - ALTER_GAME=t7x - - ALTER_CONFIG_FILE=server_zm.cfg - - ALTER_PORT=27017 - #- ALTER_MOD=2833912385 - #- ALTER_AUTO_UPDATE=false + - PLUTAINER_GAME=t7x + - PLUTAINER_CONFIG_FILE=server_zm.cfg + - PLUTAINER_PORT=27017 + #- PLUTAINER_MOD=2833912385 + #- PLUTAINER_AUTO_UPDATE=false networks: - games-net @@ -181,14 +181,16 @@ services: - ./IW4MAdmin/Localization:/app/Localization - ./IW4MAdmin/Database:/app/Database - ./IW4MAdmin/Log:/app/Log - - ./iw4xmp-1/gamefiles/userraw/logs/games_mp.log:/app/gamelogs/iw4xmp-1/gamefiles/userraw/logs/games_mp.log:ro - - ./iw5mp-1/plutonium/storage/iw5/logs/games_mp.log:/app/gamelogs/iw5mp-1/plutonium/storage/iw5/logs/games_mp.log:ro - - ./t4mp-1/plutonium/storage/t4/main/games_mp.log:/app/gamelogs/t4mp-1/plutonium/storage/t4/main/games_mp.log:ro - - ./t4sp-1/plutonium/storage/t4/main/games_zm.log:/app/gamelogs/t4sp-1/plutonium/storage/t4/main/games_zm.log:ro - - ./t5mp-1/plutonium/storage/t5/main/logs/games_mp.log:/app/gamelogs/t5mp-1/plutonium/storage/t5/main/logs/games_mp.log:ro - - ./t5sp-1/plutonium/storage/t5/mods/Stairway_to_hell_bo1/logs/games_zm.log:/app/gamelogs/t5sp-1/plutonium/storage/t5/mods/Stairway_to_hell_bo1/logs/games_zm.log:ro - - ./t6mp-1/plutonium/storage/t6/main/logs/games_mp.log:/app/gamelogs/t6mp-1/plutonium/storage/t6/main/logs/games_mp.log:ro - - ./t6zm-1/plutonium/storage/t6/main/logs/games_zm.log:/app/gamelogs/t6zm-1/plutonium/storage/t6/main/logs/games_zm.log:ro + # v2: mount each server's app/logs/ as a single read-only dir. log-watcher.sh + # maintains stable symlinks inside, regardless of where the game writes them. + - ./iw4xmp-1/logs:/app/gamelogs/iw4xmp-1:ro + - ./iw5mp-1/logs:/app/gamelogs/iw5mp-1:ro + - ./t4mp-1/logs:/app/gamelogs/t4mp-1:ro + - ./t4sp-1/logs:/app/gamelogs/t4sp-1:ro + - ./t5mp-1/logs:/app/gamelogs/t5mp-1:ro + - ./t5sp-1/logs:/app/gamelogs/t5sp-1:ro + - ./t6mp-1/logs:/app/gamelogs/t6mp-1:ro + - ./t6zm-1/logs:/app/gamelogs/t6zm-1:ro depends_on: iw4xmp-1: condition: service_healthy @@ -217,7 +219,7 @@ networks: config: - subnet: 100.64.10.0/30 gateway: 100.64.10.1 - + games-net: driver: bridge ipam: diff --git a/README.md b/README.md index 5264c53..177a59d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ This repository contains the necessary files to build and run dedicated game servers for Plutonium, IW4x, and Alterware using Docker. The image is designed to be flexible and configurable through environment variables. -The container is available on GitHub Container Registry: `ghcr.io/ayymoss/plutainer:latest` +The container is available on GitHub Container Registry: `ghcr.io/ayymoss/plutainer:main` + +> **Tag note (v2):** The `main` tag tracks the new v2 volume layout and the unified `PLUTAINER_*` environment variables. The legacy `latest` tag is deprecated and will not receive further updates. If you are migrating an existing deployment, see [Upgrading from v1](#upgrading-from-v1). ## Overview @@ -33,89 +35,144 @@ Instead of using a long `docker run` command, it is highly recommended to use `d ### Environment Variables -The container is configured entirely through environment variables. You must specify one of `PLUTO_GAME`, `IW4X_GAME`, or `ALTER_GAME`. +The container is configured entirely through environment variables. You must set `PLUTAINER_GAME` to one of the supported game tags. -#### General Variables +#### Unified (`PLUTAINER_*`) — apply to all games | Variable | Description | Default | | --- | --- | --- | -| `PLUTO_GAME` | The Plutonium game to run. **Required** for Plutonium. | | -| `IW4X_GAME` | The IW4x game to run. Must be `iw4x`. **Required** for IW4x. | | -| `ALTER_GAME` | The Alterware game to run. Must be `t7x`. **Required** for Alterware. | | +| `PLUTAINER_GAME` | **Required.** Game tag: `t4mp`, `t4sp`, `t5mp`, `t5sp`, `t6mp`, `t6zm`, `iw5mp`, `iw4x`, or `t7x`. | | +| `PLUTAINER_CONFIG_FILE` | **Required.** Filename of your server's config (e.g., `dedicated.cfg`). Lives in `app/configs/` (see [Volume Layout](#volume-layout)). | | +| `PLUTAINER_PORT` | Network port for the server. | Game-specific (see [Default Ports](#default-ports)). | +| `PLUTAINER_SERVER_NAME` | Display name used in startup logs. | Game-family-specific default. | +| `PLUTAINER_MOD` | Mod folder name (Plutonium/IW4x) or Steam Workshop ID (T7x). Omit if no mod. | | +| `PLUTAINER_AUTO_UPDATE` | Set to `"false"` to skip update checks at startup. | `true` | +| `PLUTAINER_HEALTHCHECK` | Set to `"false"` to disable the RCON health check. | `true` | +| `PLUTAINER_SKIP_SEED` | Set to `"true"` to skip first-run [config seeding](#bundled-config-seeds). | `false` | +| `PLUTAINER_EXTRA_ARGS` | Extra arguments appended to the launch command. | | +| `PLUTAINER_LOG_SYMLINKS` | Set to `"false"` to disable the [log symlink watcher](#log-symlinks). | `true` | +| `PLUTAINER_LOG_POLL_INTERVAL` | Seconds between log watcher polls. | `2` | + +#### Game-specific (unique to one stack) + +These cannot be unified because they only apply to a single engine family: + +| Variable | Description | Applies to | +| --- | --- | --- | +| `PLUTO_SERVER_KEY` | **Required for Plutonium.** Server key from . | Plutonium only | +| `PLUTO_MAX_CLIENTS` | Maximum players (Plutonium T5 only — other games set this in the cfg). | Plutonium T5 only | +| `IW4X_NET_LOG_IP` | IP:port for IW4x remote netlogging (`g_log_add`). | IW4x only | -See above for the game tags to use. (eg, `t6zm`) +#### Default ports -#### Plutonium Variables +| Game | Default | +| --- | --- | +| iw4x | 28960 | +| iw5 | 27016 | +| t4, t5 | 28960 | +| t6 | 4976 | +| t7x | 27017 | -| Variable | Description | Default | -| --- | --- | --- | -| `PLUTO_SERVER_KEY` | **Required.** Your server key from the Plutonium website. | | -| `PLUTO_CONFIG_FILE` | **Required.** The filename of your server's configuration file. | | -| `PLUTO_SERVER_NAME` | The name of your server. | "Plutonium Docker Server" | -| `PLUTO_PORT` | The network port for the server. | Game-specific default (e.g., 4976 for T6) | -| `PLUTO_MOD` | The name of the mod folder to load. Omit this if no mod needed. | | -| `PLUTO_MAX_CLIENTS` | **T5 Only!** The maximum number of players allowed. | | -| `PLUTO_AUTO_UPDATE` | Set to `"false"` to prevent the container from checking for updates on start. | `true` | -| `PLUTO_HEALTHCHECK` | Set to `"false"` to disable the health check. | `true` | -| `PLUTO_SKIP_SEED` | Set to `"true"` to skip first-run config seeding. See [Bundled Config Seeds](#bundled-config-seeds). | `false` | -| `PLUTO_EXTRA_ARGS` | Allows you to extend the start param. | | +#### Backward compatibility (deprecated old-prefix names) -(eg. `- "PLUTO_EXTRA_ARGS=+set SOMETHING=1 +set SOMETHING_ELSE=FOO"`) +The old `PLUTO_*`, `IW4X_*`, and `ALTER_*` prefixed environment variables are still accepted but will emit a `[DEPRECATED]` warning at startup. They map onto the unified names below: -#### IW4x Variables +| Old (deprecated) | New canonical | +| --- | --- | +| `PLUTO_GAME`, `IW4X_GAME`, `ALTER_GAME` | `PLUTAINER_GAME` | +| `PLUTO_CONFIG_FILE`, `IW4X_CONFIG_FILE`, `ALTER_CONFIG_FILE` | `PLUTAINER_CONFIG_FILE` | +| `PLUTO_PORT`, `IW4X_PORT`, `ALTER_PORT` | `PLUTAINER_PORT` | +| `PLUTO_SERVER_NAME`, `IW4X_SERVER_NAME`, `ALTER_SERVER_NAME` | `PLUTAINER_SERVER_NAME` | +| `PLUTO_MOD`, `IW4X_MOD`, `ALTER_MOD` | `PLUTAINER_MOD` | +| `PLUTO_AUTO_UPDATE`, `IW4X_AUTO_UPDATE`, `ALTER_AUTO_UPDATE` | `PLUTAINER_AUTO_UPDATE` | +| `PLUTO_HEALTHCHECK`, `IW4X_HEALTHCHECK`, `ALTER_HEALTHCHECK` | `PLUTAINER_HEALTHCHECK` | +| `PLUTO_SKIP_SEED`, `ALTER_SKIP_SEED` | `PLUTAINER_SKIP_SEED` | +| `PLUTO_EXTRA_ARGS`, `IW4X_EXTRA_ARGS`, `ALTER_EXTRA_ARGS` | `PLUTAINER_EXTRA_ARGS` | + +`PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, and `IW4X_NET_LOG_IP` keep their original names — they are not duplicates across families, so they don't need unification. -| Variable | Description | Default | -| --- | --- | --- | -| `IW4X_CONFIG_FILE` | **Required.** The filename of your server's configuration file. | | -| `IW4X_SERVER_NAME` | The name of your server. | "IW4x Docker Server" | -| `IW4X_PORT` | The network port for the server. | `28960` | -| `IW4X_MOD` | The name of the mod folder to load. Omit this if no mod needed. | | -| `IW4X_AUTO_UPDATE` | Set to `"false"` to prevent the container from checking for updates on start. | `true` | -| `IW4X_HEALTHCHECK` | Set to `"false"` to disable the health check. | `true` | -| `IW4X_NET_LOG_IP` | The IP address and port for remote netlogging. | | -| `IW4X_EXTRA_ARGS` | Allows you to extend the start param. | | +*** -#### Alterware Variables +### Volume Layout -| Variable | Description | Default | +The container expects two volume mounts: + +| Container path | Purpose | Recommended host mount | | --- | --- | --- | -| `ALTER_CONFIG_FILE` | **Required.** The filename of your server's configuration file (placed in `zone/`). | | -| `ALTER_SERVER_NAME` | The name of your server. | "T7x Docker Server" | -| `ALTER_PORT` | The network port for the server. | `27017` | -| `ALTER_MOD` | The Steam Workshop ID of the mod to load. Omit this if no mod needed. | | -| `ALTER_AUTO_UPDATE` | Set to `"false"` to prevent the container from checking for updates on start. | `true` | -| `ALTER_HEALTHCHECK` | Set to `"false"` to disable the health check. | `true` | -| `ALTER_SKIP_SEED` | Set to `"true"` to skip first-run config seeding. See [Bundled Config Seeds](#bundled-config-seeds). | `false` | -| `ALTER_EXTRA_ARGS` | Allows you to extend the start param. | | +| `/home/plutainer/gamefiles` | Read-only base game files you own. | Bind-mount with `:ro`. | +| `/home/plutainer/app` | Persistent server state, configs, and logs. | Bind-mount or named volume. | + +On a fresh `app/` mount, the container initialises this layout on first start: + +``` +app/ + configs/ # Your server's *.cfg files. Edit here. + logs/ # Stable symlinks to active *.log files (see Log Symlinks). + runtime/ + gamefiles/ # Symlinks into the read-only gamefiles mount, plus + # writable game state (mods, maps, plutonium storage). + plutonium/ # Plutonium binaries and storage. + .plutainer-version # "2" — marks volume layout version. +``` + +**Where to put your `*.cfg` files:** drop them in `app/configs/` and set `PLUTAINER_CONFIG_FILE` to the filename. The container creates a symlink at the engine's expected path on each start, so the game still reads from its usual location — you just have one predictable place to edit. + +Example: for a T6 server with `PLUTAINER_CONFIG_FILE=dedicated_zm.cfg`, you edit `app/configs/dedicated_zm.cfg`, and the container symlinks `app/runtime/plutonium/storage/t6/dedicated_zm.cfg → ../../../../configs/dedicated_zm.cfg`. + +Nested configs (e.g. mod-specific cfgs inside `mods//`) stay at their engine path under `app/runtime/` and are not lifted to `configs/`. You can still edit them there. *** -### Volumes and Configuration Files +### Upgrading from v1 + +If you have an existing deployment running the older `:latest` tag, your `app/` volume is in the **v1 layout** (no `configs/` or `runtime/` dirs, `gamefiles/` and `plutonium/` at the top level). The v2 container will refuse to start against a v1 volume — running it produces a clear error pointing to this section. -To persist your server configurations and provide the necessary game files, you must use Docker volumes. +Run the bundled migration tool once per volume: -* **Game Files:** You need to mount your host machine's game files directory into the container at `/home/plutainer/gamefiles`. It is highly recommended to mount this as read-only (`:ro`) to prevent the container from modifying your base game files. -* **Config Files:** Your server's `.cfg` files should be included in the default expected locations for the games. Mount `/home/plutainer/app`. The expected relative game config defaults are... - * **IW4x:** `./gamefiles/userraw/` - * **Plutonium T4 (WaW):** `./gamefiles/main/` - * **Plutonium T5 (BO1):** `./plutonium/storage/t5/` - * **Plutonium IW5 (MW3):** `./gamefiles/admin/` - * **Plutonium T6 (BO2):** `./plutonium/storage/t6/` - * **Alterware T7x (BO3):** `./gamefiles/zone/` +```sh +docker run --rm \ + -v :/home/plutainer/app \ + --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ + ghcr.io/ayymoss/plutainer:main +``` + +Replace `` with the path bound to `/home/plutainer/app` in your compose file (e.g. `./t6zm-1`). The tool: + +1. Creates `runtime/` and `configs/`. +2. Moves `app/gamefiles/` → `app/runtime/gamefiles/`. +3. Moves `app/plutonium/` → `app/runtime/plutonium/`. +4. Lifts every top-level `*.cfg` from the engine config dirs into `app/configs/`, leaving a relative symlink in its place. +5. Clears stale entries in `app/logs/` (the log-watcher repopulates on next start). +6. Writes `app/.plutainer-version=2`. + +Add `--dry-run` after the entrypoint to preview without modifying anything: + +```sh +docker run --rm \ + -v :/home/plutainer/app \ + --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ + ghcr.io/ayymoss/plutainer:main --dry-run +``` + +If you also have IW4MAdmin sidecar mounts pointing at log paths like `./t6zm-1/plutonium/storage/...`, update them to `./t6zm-1/runtime/plutonium/storage/...` — or better, switch to the stable [log symlink directory](#log-symlinks). + +*** ### Bundled Config Seeds To make first-run setup painless, the image bundles default configs from community repos and copies them into the bind-mounted `app/` volume on container start. Files that already exist are **never overwritten** — existing user configs are always kept as-is. +Top-level `*.cfg` files from each seed bundle land in `app/configs/` (flat). Other assets (mod scripts, maps, nested cfgs, lobby scripts) land under `app/runtime/` at their engine-expected paths. + | Game | Source repo | | --- | --- | | Plutonium T4 | [xerxes-at/T4ServerConfigs](https://github.com/xerxes-at/T4ServerConfigs) | | Plutonium T5 | [xerxes-at/T5ServerConfig](https://github.com/xerxes-at/T5ServerConfig) | | Plutonium T6 | [xerxes-at/T6ServerConfigs](https://github.com/xerxes-at/T6ServerConfigs) | | Plutonium IW5 | [xerxes-at/IW5ServerConfig](https://github.com/xerxes-at/IW5ServerConfig) | -| Alterware T7x | [Dss0/t7-server-config](https://github.com/Dss0/t7-server-config) (includes the `t7x/lobby_scripts/` required for `sv_lobby_mode` to work) | +| Alterware T7x | [Dss0/t7-server-config](https://github.com/Dss0/t7-server-config) (includes `t7x/lobby_scripts/` required for `sv_lobby_mode`) | -To opt out — for example if you manage configs entirely yourself and don't want any default files appearing in your bind mount — set `PLUTO_SKIP_SEED=true` (Plutonium) or `ALTER_SKIP_SEED=true` (Alterware). +To opt out — for example if you manage configs entirely yourself and don't want any default files appearing in your bind mount — set `PLUTAINER_SKIP_SEED=true`. The seed snapshot is frozen at image build time. Pulling a newer image only seeds files that don't yet exist in your bind mount, so the upstream repo never silently overwrites your edits. @@ -125,19 +182,19 @@ The seed snapshot is frozen at image build time. Pulling a newer image only seed #### Mount Permissions -When you mount volumes from your host machine into the container, the `plutainer` (with UID `1000`) needs to have the appropriate permissions to read and write to those directories. If the ownership on your host directories is incorrect, the server may fail to start or be unable to save data. +When you mount volumes from your host machine into the container, the `plutainer` user (with UID `1000`) needs to have the appropriate permissions to read and write to those directories. If the ownership on your host directories is incorrect, the server may fail to start or be unable to save data. -On many desktop Linux distributions (like Debian), the first user you create is automatically assigned UID `1000`. If you are that user, you may not need to do anything. However, if you created the directories as `root` (e.g., using `sudo mkdir`), you will need to update their ownership. +On many desktop Linux distributions, the first user you create is automatically assigned UID `1000`. If you are that user, you may not need to do anything. However, if you created the directories as `root` (e.g., using `sudo mkdir`), you will need to update their ownership. #### How to Fix Permissions -To ensure the container has the correct access, you should change the ownership of your persistent data directory to match the container's user. Run the following command on your host machine, adjusting the path to match your setup: +To ensure the container has the correct access, change the ownership of your persistent data directory to match the container's user. Run the following command on your host machine, adjusting the path to match your setup: ```sh sudo chown -R 1000:1000 /opt/pluto-servers/t6zm-server-1/ ``` -The `-R` flag applies the ownership recursively, ensuring all files and sub-folders have the correct permissions. While the container only needs to *read* the game files, applying correct ownership to that volume as well is good practice to avoid any potential read-related issues. +The `-R` flag applies the ownership recursively, ensuring all files and sub-folders have the correct permissions. *** @@ -157,6 +214,23 @@ Your server configuration file must have `rcon_password` set for `rcon-cli` to w *** +### Log Symlinks + +The container maintains a flat directory of symlinks at `app/logs/` pointing at the active `*.log` file for each basename. Game logs move around per game/mod (e.g. `runtime/plutonium/storage/t5/mods//logs/games_zm.log`); the watcher surfaces them all in one predictable place so IW4MAdmin (or any other log reader) doesn't have to chase the exact path. + +Mount `app/logs/` as the source for downstream log consumers: + +```yaml +volumes: + - ./t6zm-1/logs:/app/gamelogs/t6zm-1:ro +``` + +Symlinks are relative, so they resolve correctly from the host, this container, or a sidecar container mounting the same `app/` volume. + +Disable with `PLUTAINER_LOG_SYMLINKS=false`; change poll interval with `PLUTAINER_LOG_POLL_INTERVAL` (default 2s). + +*** + ### Advanced: IW4MAdmin & RCON Connecting a containerized IW4MAdmin to your Plutainer server requires special network configuration due to the way Docker handles container-container networking via its proxy. @@ -196,24 +270,24 @@ This issue does **not** occur if you are running IW4MAdmin directly on the host The container includes a robust health check script that verifies the server is running and responsive. It works by: 1. Detecting the game type and port. -2. Locating your server configuration file. +2. Locating your server configuration file in `app/configs/`. 3. Extracting your `rcon_password` from the config. 4. Sending an RCON `status` command to the server. 5. Checking for a valid response. -The health check is enabled by default. You can disable it by setting the corresponding environment variable (`PLUTO_HEALTHCHECK`, `IW4X_HEALTHCHECK`, or `ALTER_HEALTHCHECK`) to `"false"`. This can be useful for debugging or if you do not wish to set an RCON password. +The health check is enabled by default. You can disable it by setting `PLUTAINER_HEALTHCHECK=false`. This can be useful for debugging or if you do not wish to set an RCON password. -Please note that for the healthcheck to work correctly, games that support RCon whitelists need to have localhost permitted and/or "127.0.0.1" +For the healthcheck to work correctly, games that support RCon whitelists need to have localhost permitted and/or `127.0.0.1`. -To have your servers restarted automatically, simply add [Auto Heal](https://github.com/willfarrell/docker-autoheal) to the compose. +To have your servers restarted automatically, add [Auto Heal](https://github.com/willfarrell/docker-autoheal) to the compose. *** ### Support? -Discord Support: https://discord.gg/PjrFw4tNES +Discord Support: -Please note that I will not be supporting Plutonium-specific issues. There is an expectation that you're already familiar with Docker. If you're brand new, please visit https://docs.docker.com/get-started/ +Please note that I will not be supporting Plutonium-specific issues. There is an expectation that you're already familiar with Docker. If you're brand new, please visit This Discord is to be specific to Plutainer and its setup and configuration (including IW4MAdmin). @@ -221,5 +295,5 @@ This Discord is to be specific to Plutainer and its setup and configuration (inc #### Credits -- Corey, for a production testing ground @ https://cukservers.net/ -- HGM, for the name 'Plutainer' @ https://hgmserve.rs/ +- Corey, for a production testing ground @ +- HGM, for the name 'Plutainer' @ diff --git a/scripts/alterentry.sh b/scripts/alterentry.sh index d07e4bd..f16e32d 100644 --- a/scripts/alterentry.sh +++ b/scripts/alterentry.sh @@ -1,20 +1,25 @@ #!/bin/bash # -# This entrypoint script is responsible for validating the container's -# environment, setting sensible defaults, and launching the specified -# T7x (Black Ops 3) game server. +# Validate environment, prepare the game-files tree and configs/ symlinks, +# fetch t7x.exe, then launch the Alterware (T7x) server. # +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/game-config.sh" -# --- Step 1: Link Game Files --- -SOURCE_DIR="/home/plutainer/gamefiles" -DEST_DIR="/home/plutainer/app/gamefiles" +detect_game_type || exit 1 +check_volume_version || exit 1 +resolve_engine_config_dir || exit 1 + +SOURCE_DIR="$PLUTAINER_SOURCE_DIR" +DEST_DIR="$PLUTAINER_GAMEFILES_DIR" mkdir -p "$DEST_DIR" +# --- Step 1: Link Game Files --- echo "Linking files for t7x (Black Ops III)..." -ln -sf "$SOURCE_DIR"/{codlogo.bmp,machinecfg,steam_api64.dll,steamclient64.dll,tier0_s64.dll,vstdlib_s64.dll} "$DEST_DIR"/ +link_files "$SOURCE_DIR" "$DEST_DIR" \ + codlogo.bmp machinecfg steam_api64.dll steamclient64.dll tier0_s64.dll vstdlib_s64.dll # T7x detects dedicated server mode by checking which executables exist: # is_server = has_flag("dedicated") || (!has_client && has_server) @@ -28,7 +33,7 @@ elif [[ -f "$SOURCE_DIR/BlackOps3.exe" ]]; then fi # Create zone/ as a real directory with symlinked contents so configs can be -# placed alongside the read-only game data (same approach as T4's main/) +# placed alongside the read-only game data (same approach as T4's main/). mkdir -p "$DEST_DIR/zone" # Guard the glob: bash leaves unmatched `*` literal, so an empty/missing # source zone/ would create a bogus symlink named `*`. @@ -36,85 +41,80 @@ if compgen -G "$SOURCE_DIR/zone/*" > /dev/null; then ln -sf "$SOURCE_DIR"/zone/* "$DEST_DIR"/zone/ fi - # --- Step 2: Download/Update T7x --- +# wget -N uses timestamping: it sends If-Modified-Since and only downloads +# when upstream is newer than the local file. Avoids redownloading on every +# restart. ALTER_EXE_LOC="$DEST_DIR/t7x.exe" -if [[ ! -f "${ALTER_EXE_LOC}" ]]; then - echo "First container run detected. Downloading T7x... This may take a moment." - wget -q -O "$ALTER_EXE_LOC" https://master.bo3.eu/t7x/t7x.exe +if [[ -f "$ALTER_EXE_LOC" && "${PLUTAINER_AUTO_UPDATE:-}" == "false" ]]; then + echo "Skipping T7x update because PLUTAINER_AUTO_UPDATE is set to 'false'." else - if [[ "${ALTER_AUTO_UPDATE}" == "false" ]]; then - echo "Skipping T7x update because ALTER_AUTO_UPDATE is set to 'false'." - else + if [[ -f "$ALTER_EXE_LOC" ]]; then echo "Checking for T7x updates..." - wget -q -O "$ALTER_EXE_LOC" https://master.bo3.eu/t7x/t7x.exe + else + echo "First container run detected. Downloading T7x... This may take a moment." fi + wget -q -N -P "$DEST_DIR" https://master.bo3.eu/t7x/t7x.exe fi cd "$DEST_DIR" -# --- Step 2.5: Seed default configs from bundled community repo --- -# Idempotent: cp -n means existing user files are never overwritten. -# Skip with ALTER_SKIP_SEED=true. -if [[ "${ALTER_SKIP_SEED}" != "true" ]]; then - seed_configs t7x /home/plutainer/app/gamefiles +# --- Step 3: Seed default configs from bundled community repo --- +# t7x seed bundle has two top-level dirs: `zone/` (configs) and `t7x/` (lobby +# scripts). cfg_root_rel="zone" lifts top-level `zone/*.cfg` files into +# app/configs/ flat; everything else stays under runtime/gamefiles/. +if [[ "${PLUTAINER_SKIP_SEED:-}" != "true" ]]; then + seed_configs t7x "$DEST_DIR" "zone" fi -# --- Step 3: Validate Required Environment Variables --- +# --- Step 4: Fan-out configs/ → engine config dir --- +link_configs "$ENGINE_CONFIG_DIR" + +# --- Step 5: Validate Required Environment Variables --- MISSING_VAR=false -INVALID_VAR=false -VALID_GAMES="t7x" -ALTER_SERVER_NAME=${ALTER_SERVER_NAME:-"T7x Docker Server"} +PLUTAINER_SERVER_NAME="${PLUTAINER_SERVER_NAME:-T7x Docker Server}" -if [[ -z "${ALTER_GAME}" ]]; then - echo "[ERROR] The 'ALTER_GAME' environment variable is not set." >&2 +if [[ "${PLUTAINER_GAME}" != "t7x" ]]; then + echo "[ERROR] PLUTAINER_GAME must be 't7x' for the Alterware entrypoint." >&2 MISSING_VAR=true -elif [[ ! " ${VALID_GAMES} " =~ " ${ALTER_GAME} " ]]; then - echo "[ERROR] Invalid value for 'ALTER_GAME': \"${ALTER_GAME}\"." >&2 - INVALID_VAR=true fi - -if [[ -z "${ALTER_CONFIG_FILE}" ]]; then - echo "[ERROR] The 'ALTER_CONFIG_FILE' environment variable is not set." >&2 - echo " > You must specify the name of the server configuration file (e.g., 'server_zm.cfg')." >&2 +if [[ -z "${PLUTAINER_CONFIG_FILE:-}" ]]; then + echo "[ERROR] PLUTAINER_CONFIG_FILE is not set." >&2 + echo " > Filename of your server config (e.g. 'server_zm.cfg')." >&2 MISSING_VAR=true fi -if [[ "$MISSING_VAR" == "true" || "$INVALID_VAR" == "true" ]]; then +if [[ "$MISSING_VAR" == "true" ]]; then echo "-------------------------------------------------" >&2 - if [[ "$INVALID_VAR" == "true" ]]; then - echo "An invalid value was provided. Valid game modes are: ${VALID_GAMES}" >&2 - fi - echo "One or more configuration errors found. Halting startup." >&2 - echo "Exiting in 10 seconds..." >&2 + echo "Configuration error. Halting startup." >&2 sleep 10 exit 1 fi -# --- Step 4: Set Default Port (If Needed) --- -if [[ -z "${ALTER_PORT}" ]]; then - echo "Optional ALTER_PORT is not set, determining default for ${ALTER_GAME}..." - resolve_default_port "${ALTER_GAME}" || { sleep 10; exit 1; } - ALTER_PORT="${DEFAULT_PORT}" - echo "Default port set to ${ALTER_PORT}" +# --- Step 6: Resolve port --- +if [[ -z "${PLUTAINER_PORT:-}" ]]; then + echo "PLUTAINER_PORT not set, using default for t7x..." + resolve_default_port "t7x" || { sleep 10; exit 1; } + PLUTAINER_PORT="${DEFAULT_PORT}" + echo "Default port set to ${PLUTAINER_PORT}" fi -# --- Step 5: Build Server Command Arguments --- +# --- Step 7: Build Server Command Arguments --- declare -a CMD_ARGS=( -dedicated - +set fs_game "${ALTER_MOD:-}" - +set net_port "${ALTER_PORT}" + +set fs_game "${PLUTAINER_MOD:-}" + +set net_port "${PLUTAINER_PORT}" +set logfile "2" - +exec "${ALTER_CONFIG_FILE}" + +exec "${PLUTAINER_CONFIG_FILE}" ) -if [[ -n "${ALTER_EXTRA_ARGS}" ]]; then - CMD_ARGS+=(${ALTER_EXTRA_ARGS}) +if [[ -n "${PLUTAINER_EXTRA_ARGS:-}" ]]; then + CMD_ARGS+=(${PLUTAINER_EXTRA_ARGS}) fi -# --- Step 6: Launch the T7x Server --- +# --- Step 8: Launch --- # T7x requires a display even in dedicated/headless mode, so start a virtual -# framebuffer for Wine before launching +# framebuffer for Wine before launching. echo "Starting virtual display..." rm -f /tmp/.X99-lock Xvfb :99 -screen 0 320x240x24 & @@ -122,6 +122,6 @@ sleep 1 /home/plutainer/.plutainer/log-watcher.sh & -echo "Starting T7x Server: ${ALTER_SERVER_NAME}" -echo "EXECUTING: wine t7x.exe ${CMD_ARGS[@]}" +echo "Starting T7x Server: ${PLUTAINER_SERVER_NAME}" +echo "EXECUTING: wine t7x.exe ${CMD_ARGS[*]}" exec wine t7x.exe "${CMD_ARGS[@]}" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 04fc11e..b365794 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,8 +1,13 @@ #!/bin/bash # -# This entrypoint script is responsible for branding and delegating the -# server startup to the appropriate game-specific script. +# Top-level entrypoint. Applies backward-compat shims for old prefixed env +# vars, detects the game family, validates the volume version, then delegates +# to the appropriate game-specific entry script. # +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/game-config.sh" # --- Branding --- cat << "EOF" @@ -19,20 +24,37 @@ echo echo "Brought to you by Ayymoss" echo -if [[ -n "${PLUTO_GAME}" ]]; then - echo "Plutonium game type detected. Handing off to Plutonium entrypoint..." - exec /home/plutainer/.plutainer/plutoentry.sh -elif [[ -n "${IW4X_GAME}" ]]; then - echo "IW4x game type detected. Handing off to IW4x entrypoint..." - exec /home/plutainer/.plutainer/iw4xentry.sh -elif [[ -n "${ALTER_GAME}" ]]; then - echo "Alterware game type detected (${ALTER_GAME}). Handing off to Alterware entrypoint..." - exec /home/plutainer/.plutainer/alterentry.sh -else +# --- Shim old-prefix env vars (PLUTO_/IW4X_/ALTER_) into PLUTAINER_* --- +shim_env_vars + +# --- Detect game type from PLUTAINER_GAME --- +if ! detect_game_type; then echo "-------------------------------------------------" >&2 - echo "[ERROR] No game type specified." >&2 - echo " > Please set PLUTO_GAME, IW4X_GAME, or ALTER_GAME environment variable." >&2 + echo "Set PLUTAINER_GAME to one of: t4mp, t4sp, t5mp, t5sp, t6mp, t6zm, iw5mp, iw4x, t7x" >&2 + echo "Exiting in 10 seconds..." >&2 + sleep 10 + exit 1 +fi + +# --- Validate (or initialise) volume layout --- +if ! check_volume_version; then echo "Exiting in 10 seconds..." >&2 sleep 10 exit 1 fi + +# --- Dispatch to game-specific entrypoint --- +case "$GAME_TYPE" in + plutonium) + echo "Plutonium game detected (${GAME_NAME}). Handing off to Plutonium entrypoint..." + exec "$SCRIPT_DIR/plutoentry.sh" + ;; + iw4x) + echo "IW4x game detected. Handing off to IW4x entrypoint..." + exec "$SCRIPT_DIR/iw4xentry.sh" + ;; + alterware) + echo "Alterware game detected (${GAME_NAME}). Handing off to Alterware entrypoint..." + exec "$SCRIPT_DIR/alterentry.sh" + ;; +esac diff --git a/scripts/game-config.sh b/scripts/game-config.sh index 8a8f2f1..f17ca6a 100644 --- a/scripts/game-config.sh +++ b/scripts/game-config.sh @@ -1,42 +1,88 @@ #!/bin/bash # # Shared game configuration library. -# Sourced by entrypoint scripts, healthcheck, and rcon-cli to avoid -# duplicating port defaults, config path resolution, and game detection. +# Sourced by entrypoint scripts, healthcheck, and rcon-cli. # +# Volume layout (v2): +# /home/plutainer/app/ +# configs/ # User-facing config files (flat). +# # Real files. Engine paths symlink here. +# logs/ # Stable symlinks to active *.log files +# # (maintained by log-watcher.sh). +# runtime/ +# gamefiles/ # Symlinks into host /home/plutainer/gamefiles +# # plus writable game state. +# plutonium/ # Plutonium binaries + storage state. +# .plutainer-version # Layout marker (contains "2"). +# + +PLUTAINER_VOLUME_VERSION=2 +PLUTAINER_APP_DIR="/home/plutainer/app" +PLUTAINER_CONFIGS_DIR="$PLUTAINER_APP_DIR/configs" +PLUTAINER_RUNTIME_DIR="$PLUTAINER_APP_DIR/runtime" +PLUTAINER_GAMEFILES_DIR="$PLUTAINER_RUNTIME_DIR/gamefiles" +PLUTAINER_PLUTONIUM_DIR="$PLUTAINER_RUNTIME_DIR/plutonium" +PLUTAINER_SOURCE_DIR="/home/plutainer/gamefiles" + +# Map old-prefix env vars (PLUTO_*, IW4X_*, ALTER_*) to canonical PLUTAINER_* +# names. Only sets PLUTAINER_ if it is currently unset. Emits a single +# deprecation warning per old name that's actually being used. +shim_env_vars() { + local suffix prefix old new + for suffix in GAME CONFIG_FILE PORT SERVER_NAME MOD AUTO_UPDATE HEALTHCHECK SKIP_SEED EXTRA_ARGS; do + new="PLUTAINER_$suffix" + [[ -n "${!new}" ]] && continue + for prefix in PLUTO IW4X ALTER; do + old="${prefix}_${suffix}" + if [[ -n "${!old}" ]]; then + printf -v "$new" '%s' "${!old}" + export "$new" + echo "[DEPRECATED] ${old} is renamed to ${new}; old name still accepted for now." >&2 + break + fi + done + done +} -# Detect the game type from environment variables and set unified variables: -# GAME_TYPE, GAME_NAME, BASE_GAME, CONFIG_FILE, CUSTOM_PORT, HEALTHCHECK_FLAG +# Derive the game family ("plutonium", "iw4x", "alterware") from PLUTAINER_GAME. +# Returns 1 if unknown. +derive_family() { + case "$1" in + iw5mp|t4mp|t4sp|t5mp|t5sp|t6mp|t6zm) echo "plutonium" ;; + iw4x) echo "iw4x" ;; + t7x) echo "alterware" ;; + *) return 1 ;; + esac +} + +# Populate GAME_TYPE, GAME_NAME, BASE_GAME, CONFIG_FILE, CUSTOM_PORT, +# HEALTHCHECK_FLAG from PLUTAINER_* (applying back-compat shims first). detect_game_type() { - if [[ -n "${PLUTO_GAME}" ]]; then - GAME_TYPE="plutonium" - GAME_NAME="${PLUTO_GAME}" - BASE_GAME="${PLUTO_GAME%??}" - CONFIG_FILE="${PLUTO_CONFIG_FILE}" - CUSTOM_PORT="${PLUTO_PORT}" - HEALTHCHECK_FLAG="${PLUTO_HEALTHCHECK}" - elif [[ -n "${IW4X_GAME}" ]]; then - GAME_TYPE="iw4x" - GAME_NAME="${IW4X_GAME}" - BASE_GAME="iw4x" - CONFIG_FILE="${IW4X_CONFIG_FILE}" - CUSTOM_PORT="${IW4X_PORT}" - HEALTHCHECK_FLAG="${IW4X_HEALTHCHECK}" - elif [[ -n "${ALTER_GAME}" ]]; then - GAME_TYPE="alterware" - GAME_NAME="${ALTER_GAME}" - BASE_GAME="${ALTER_GAME}" - CONFIG_FILE="${ALTER_CONFIG_FILE}" - CUSTOM_PORT="${ALTER_PORT}" - HEALTHCHECK_FLAG="${ALTER_HEALTHCHECK}" - else - echo "[ERROR] No game type detected. Set PLUTO_GAME, IW4X_GAME, or ALTER_GAME." >&2 + shim_env_vars + + if [[ -z "${PLUTAINER_GAME}" ]]; then + echo "[ERROR] No game specified. Set PLUTAINER_GAME (e.g. t6zm, iw4x, t7x)." >&2 return 1 fi + + GAME_NAME="${PLUTAINER_GAME}" + GAME_TYPE="$(derive_family "$GAME_NAME")" || { + echo "[ERROR] Unknown PLUTAINER_GAME value: '${GAME_NAME}'." >&2 + return 1 + } + + case "$GAME_TYPE" in + plutonium) BASE_GAME="${GAME_NAME%??}" ;; + iw4x) BASE_GAME="iw4x" ;; + alterware) BASE_GAME="${GAME_NAME}" ;; + esac + + CONFIG_FILE="${PLUTAINER_CONFIG_FILE}" + CUSTOM_PORT="${PLUTAINER_PORT}" + HEALTHCHECK_FLAG="${PLUTAINER_HEALTHCHECK}" } -# Resolve the default port for a given BASE_GAME. -# Sets DEFAULT_PORT. Returns 1 if the game is unknown. +# Set DEFAULT_PORT based on BASE_GAME (or the arg). resolve_default_port() { local base_game="${1:-$BASE_GAME}" case "${base_game}" in @@ -52,29 +98,19 @@ resolve_default_port() { esac } -# Resolve the config file path for the current game. -# Sets CONFIG_PATH. Requires GAME_TYPE, BASE_GAME, and CONFIG_FILE to be set. -resolve_config_path() { +# Set ENGINE_CONFIG_DIR — the directory where the game engine reads cfg files +# from. Entrypoints place symlinks here that point back into PLUTAINER_CONFIGS_DIR. +resolve_engine_config_dir() { case "${GAME_TYPE}" in - "plutonium") + plutonium) case "${BASE_GAME}" in - "t4") CONFIG_PATH="/home/plutainer/app/gamefiles/main/${CONFIG_FILE}" ;; - "iw5") CONFIG_PATH="/home/plutainer/app/gamefiles/admin/${CONFIG_FILE}" ;; - *) CONFIG_PATH="/home/plutainer/app/plutonium/storage/${BASE_GAME}/${CONFIG_FILE}" ;; - esac - ;; - "iw4x") - CONFIG_PATH="/home/plutainer/app/gamefiles/userraw/${CONFIG_FILE}" - ;; - "alterware") - case "${BASE_GAME}" in - "t7x") CONFIG_PATH="/home/plutainer/app/gamefiles/zone/${CONFIG_FILE}" ;; - *) - echo "[ERROR] Unknown Alterware game '${BASE_GAME}'." >&2 - return 1 - ;; + t4) ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/main" ;; + iw5) ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/admin" ;; + *) ENGINE_CONFIG_DIR="$PLUTAINER_PLUTONIUM_DIR/storage/${BASE_GAME}" ;; esac ;; + iw4x) ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/userraw" ;; + alterware) ENGINE_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/zone" ;; *) echo "[ERROR] Unknown game type '${GAME_TYPE}'." >&2 return 1 @@ -82,18 +118,150 @@ resolve_config_path() { esac } -# Copy bundled seed configs into a destination directory without overwriting -# anything the user has already placed there. -# Args: +# Set CONFIG_PATH to the user-facing real config file in configs/. +# Requires CONFIG_FILE to be set. +resolve_config_path() { + CONFIG_PATH="$PLUTAINER_CONFIGS_DIR/${CONFIG_FILE}" +} + +# Symlink specific named entries from a source dir into a destination dir. +# Skips (with a warning) any names that don't exist — avoids the dangling +# symlink trap that bash brace expansion `{a,b,c}` creates when files are +# missing. Existing dest entries are replaced (ln -sf). +# Usage: link_files [name2 ...] +link_files() { + local src="$1" dest="$2" + shift 2 + local name + for name in "$@"; do + if [[ -e "$src/$name" ]]; then + ln -sf "$src/$name" "$dest/" + else + echo "[WARN] missing $src/$name — skipping symlink" >&2 + fi + done +} + +# Copy bundled community seed configs into the volume on first run. +# Strategy: +# - Top-level *.cfg files inside the seed's "config root" subdir +# (cfg_root_rel within the seed bundle) are placed flat in app/configs/ +# so the user can edit them all in one directory. +# - Everything else (assets, mod scripts, nested cfgs, etc) is placed under +# asset_root, preserving the seed's relative path. +# Always idempotent: never overwrites a file that already exists. +# Args: +# game-key: subdir under .plutainer/seed-configs/ to read from +# asset_root: destination for non-flat-cfg assets (typically runtime/...) +# cfg_root_rel: path within the seed (and within asset_root) where the +# engine reads top-level cfg files. Use "" if the seed root +# IS the engine config dir. seed_configs() { - local game="$1" dest="$2" + local game="$1" asset_root="$2" cfg_root_rel="${3:-}" local src="/home/plutainer/.plutainer/seed-configs/${game}" [[ -d "$src" ]] || return 0 - mkdir -p "$dest" - cp -rn "$src/." "$dest/" 2>/dev/null || true + mkdir -p "$asset_root" "$PLUTAINER_CONFIGS_DIR" + + local rel parent dest + while IFS= read -r -d '' relpath; do + rel="${relpath#./}" + parent="$(dirname "$rel")" + [[ "$parent" == "." ]] && parent="" + if [[ "$rel" == *.cfg && "$parent" == "$cfg_root_rel" ]]; then + dest="$PLUTAINER_CONFIGS_DIR/$(basename "$rel")" + else + dest="$asset_root/$rel" + fi + mkdir -p "$(dirname "$dest")" + [[ -e "$dest" ]] || cp "$src/$rel" "$dest" + done < <(cd "$src" && find . -type f -print0) +} + +# Place a symlink in / for every *.cfg file in +# app/configs/. Symlinks are relative so they resolve the same on host or in +# sidecar containers. Removes dangling cfg symlinks that point into configs/ +# but whose source has been deleted. +# Args: +link_configs() { + local engine_config_dir="$1" + [[ -d "$PLUTAINER_CONFIGS_DIR" ]] || return 0 + mkdir -p "$engine_config_dir" + + local f base link target_rel + for f in "$PLUTAINER_CONFIGS_DIR"/*.cfg; do + [[ -e "$f" ]] || continue + base="$(basename "$f")" + link="$engine_config_dir/$base" + target_rel=$(realpath --relative-to="$engine_config_dir" "$f") + ln -sfn "$target_rel" "$link" + done + + for link in "$engine_config_dir"/*.cfg; do + [[ -L "$link" && ! -e "$link" ]] || continue + echo "[link_configs] reaping dangling: $link" >&2 + rm -f "$link" + done +} + +# Check that the mounted app/ volume is v2-shaped. On a fresh volume, +# initialise it. On a v1 volume, refuse to start with explicit migration +# instructions. On a v2 volume, just ensure the expected dirs exist. +check_volume_version() { + local marker="$PLUTAINER_APP_DIR/.plutainer-version" + + if [[ -f "$marker" ]]; then + local v + v="$(cat "$marker" 2>/dev/null || echo "")" + if [[ "$v" != "$PLUTAINER_VOLUME_VERSION" ]]; then + echo "[ERROR] Volume marker reports version '$v'; this image expects '$PLUTAINER_VOLUME_VERSION'." >&2 + echo "[ERROR] You appear to be running an older image against a newer volume, or vice-versa." >&2 + return 1 + fi + mkdir -p "$PLUTAINER_CONFIGS_DIR" "$PLUTAINER_APP_DIR/logs" "$PLUTAINER_RUNTIME_DIR" + return 0 + fi + + # Marker missing. Distinguish v1 volume vs fresh volume. + if [[ -d "$PLUTAINER_APP_DIR/plutonium" || -d "$PLUTAINER_APP_DIR/gamefiles" ]]; then + cat >&2 <<'EOF' +======================================================================== +[ERROR] v1 volume layout detected. This image (Plutainer v2) uses a new +layout that places user-editable configs in a single, predictable +directory. + + v1 layout: v2 layout: + app/gamefiles/ app/configs/ (your cfgs) + app/plutonium/ app/runtime/gamefiles/ + app/logs/ app/runtime/plutonium/ + app/logs/ + +To migrate this volume, stop the container and run the bundled migration +tool against your bind mount: + + docker run --rm \ + -v :/home/plutainer/app \ + --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ + ghcr.io/ayymoss/plutainer:main + +(For a docker-compose deployment, is the path bound to +/home/plutainer/app — e.g. ./t6zm-1) + +The tool moves files in place; configs become symlinks back into the new +app/configs/ tree. No data is deleted. Re-run with --dry-run to preview. + +Refusing to start to avoid corrupting your data. +======================================================================== +EOF + return 1 + fi + + # Fresh volume — initialise v2. + mkdir -p "$PLUTAINER_CONFIGS_DIR" "$PLUTAINER_APP_DIR/logs" "$PLUTAINER_RUNTIME_DIR" + echo "$PLUTAINER_VOLUME_VERSION" > "$marker" + echo "[INFO] Initialised fresh v2 volume at $PLUTAINER_APP_DIR" } -# Extract the RCON password from a config file. +# Extract the RCON password from the user's config file in configs/. # Sets RCON_PASSWORD. Requires CONFIG_PATH to be set. extract_rcon_password() { if [[ ! -f "${CONFIG_PATH}" ]]; then diff --git a/scripts/iw4xentry.sh b/scripts/iw4xentry.sh index ba3598a..9929f46 100644 --- a/scripts/iw4xentry.sh +++ b/scripts/iw4xentry.sh @@ -1,99 +1,99 @@ #!/bin/bash # -# This entrypoint script is responsible for validating the container's -# environment, setting sensible defaults, and launching the specified -# IW4x game server. +# Validate environment, prepare the game-files tree and configs/ symlinks, +# update iw4x via the iw4x-launcher, then launch the iw4x server. # +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/game-config.sh" -# --- Step 1: Update IW4x Files --- -mkdir -p /home/plutainer/app/gamefiles -ln -sf /home/plutainer/gamefiles/{main,zone,binkw32.dll,localization.txt,mss32.dll} /home/plutainer/app/gamefiles/ +detect_game_type || exit 1 +check_volume_version || exit 1 +resolve_engine_config_dir || exit 1 -IW4X_CACHE_LOC="/home/plutainer/app/gamefiles/launcher/cache.json" -if [[ ! -f "${IW4X_CACHE_LOC}" ]]; then - echo "First container run detected. Downloading iw4x (IW4x) initial files... This may take a few minutes." - /home/plutainer/.plutainer/iw4x-launcher --path /home/plutainer/app/gamefiles --skip-launch --no-self-update +SOURCE_DIR="$PLUTAINER_SOURCE_DIR" +DEST_DIR="$PLUTAINER_GAMEFILES_DIR" +mkdir -p "$DEST_DIR" + +# --- Step 1: Link Game Files --- +echo "Linking files for iw4x..." +link_files "$SOURCE_DIR" "$DEST_DIR" main zone binkw32.dll localization.txt mss32.dll + +# --- Step 2: Update iw4x --- +IW4X_CACHE_LOC="$DEST_DIR/launcher/cache.json" +if [[ -f "$IW4X_CACHE_LOC" && "${PLUTAINER_AUTO_UPDATE:-}" == "false" ]]; then + echo "Skipping iw4x update because PLUTAINER_AUTO_UPDATE is set to 'false'." else - if [[ "${IW4X_AUTO_UPDATE}" == "false" ]]; then - echo "Skipping iw4x update because IW4X_AUTO_UPDATE is set to 'false'." + if [[ -f "$IW4X_CACHE_LOC" ]]; then + echo "Checking for iw4x updates..." else - echo "Checking for iw4x updates... This may take a few minutes if an update is available." - /home/plutainer/.plutainer/iw4x-launcher --path /home/plutainer/app/gamefiles --skip-launch --no-self-update + echo "First container run detected. Downloading iw4x initial files..." fi + /home/plutainer/.plutainer/iw4x-launcher --path "$DEST_DIR" --skip-launch --no-self-update fi -cd /home/plutainer/app/gamefiles +cd "$DEST_DIR" -# --- Step 2: Validate Required Environment Variables --- +# --- Step 3: Fan-out configs/ → engine config dir --- +# No seed_configs call: iw4x has no bundled community seed. +link_configs "$ENGINE_CONFIG_DIR" + +# --- Step 4: Validate Required Environment Variables --- MISSING_VAR=false -INVALID_VAR=false -VALID_GAMES="iw4x" -IW4X_SERVER_NAME=${IW4X_SERVER_NAME:-"IW4x Docker Server"} +PLUTAINER_SERVER_NAME="${PLUTAINER_SERVER_NAME:-IW4x Docker Server}" -if [[ -z "${IW4X_GAME}" ]]; then - echo "[ERROR] The 'IW4X_GAME' environment variable is not set." >&2 +if [[ "${PLUTAINER_GAME}" != "iw4x" ]]; then + echo "[ERROR] PLUTAINER_GAME must be 'iw4x' for the iw4x entrypoint." >&2 MISSING_VAR=true -elif [[ ! " ${VALID_GAMES} " =~ " ${IW4X_GAME} " ]]; then - echo "[ERROR] Invalid value for 'IW4X_GAME': \"${IW4X_GAME}\"." >&2 - INVALID_VAR=true fi - -if [[ -z "${IW4X_CONFIG_FILE}" ]]; then - echo "[ERROR] The 'IW4X_CONFIG_FILE' environment variable is not set." >&2 - echo " > You must specify the name of the server configuration file (e.g., 'server.cfg')." >&2 +if [[ -z "${PLUTAINER_CONFIG_FILE:-}" ]]; then + echo "[ERROR] PLUTAINER_CONFIG_FILE is not set." >&2 + echo " > Filename of your server config (e.g. 'server.cfg')." >&2 MISSING_VAR=true fi -if [[ "$MISSING_VAR" == "true" || "$INVALID_VAR" == "true" ]]; then +if [[ "$MISSING_VAR" == "true" ]]; then echo "-------------------------------------------------" >&2 - if [[ "$INVALID_VAR" == "true" ]]; then - echo "An invalid value was provided. Valid game modes are: ${VALID_GAMES}" >&2 - fi - echo "One or more configuration errors found. Halting startup." >&2 - echo "Exiting in 10 seconds..." >&2 + echo "Configuration error. Halting startup." >&2 sleep 10 exit 1 fi -# --- Step 3: Set Default Port (If Needed) --- -if [[ -z "${IW4X_PORT}" ]]; then - echo "Optional IW4X_PORT is not set, determining default for ${IW4X_GAME}..." +# --- Step 5: Resolve port --- +if [[ -z "${PLUTAINER_PORT:-}" ]]; then + echo "PLUTAINER_PORT not set, using default for iw4x..." resolve_default_port "iw4x" || { sleep 10; exit 1; } - IW4X_PORT="${DEFAULT_PORT}" - echo "Default port set to ${IW4X_PORT}" + PLUTAINER_PORT="${DEFAULT_PORT}" + echo "Default port set to ${PLUTAINER_PORT}" fi -# --- Step 4: Build Server Command Arguments --- +# --- Step 6: Build Server Command Arguments --- declare -a CMD_ARGS=( -dedicated -stdout +set sv_lanonly "0" - +set net_port "${IW4X_PORT}" - +exec "${IW4X_CONFIG_FILE}" + +set net_port "${PLUTAINER_PORT}" + +exec "${PLUTAINER_CONFIG_FILE}" +set logfile "1" +set party_enable "0" ) -if [[ -n "${IW4X_MOD}" ]]; then - CMD_ARGS+=(+set fs_game "${IW4X_MOD}") +if [[ -n "${PLUTAINER_MOD:-}" ]]; then + CMD_ARGS+=(+set fs_game "${PLUTAINER_MOD}") fi - -if [[ -n "${IW4X_NET_LOG_IP}" ]]; then +if [[ -n "${IW4X_NET_LOG_IP:-}" ]]; then CMD_ARGS+=(+set g_log_add "${IW4X_NET_LOG_IP}") fi - -if [[ -n "${IW4X_EXTRA_ARGS}" ]]; then - CMD_ARGS+=(${IW4X_EXTRA_ARGS}) +if [[ -n "${PLUTAINER_EXTRA_ARGS:-}" ]]; then + CMD_ARGS+=(${PLUTAINER_EXTRA_ARGS}) fi CMD_ARGS+=(+map_rotate) -# --- Step 5: Launch the iw4x Server --- +# --- Step 7: Launch --- /home/plutainer/.plutainer/log-watcher.sh & -echo "Starting ${IW4X_GAME} Server: ${IW4X_SERVER_NAME}" -echo "EXECUTING: wine iw4x.exe ${CMD_ARGS[@]}" +echo "Starting iw4x Server: ${PLUTAINER_SERVER_NAME}" +echo "EXECUTING: wine iw4x.exe ${CMD_ARGS[*]}" exec wine iw4x.exe "${CMD_ARGS[@]}" diff --git a/scripts/migrate-v1-to-v2.sh b/scripts/migrate-v1-to-v2.sh new file mode 100644 index 0000000..b95c1b4 --- /dev/null +++ b/scripts/migrate-v1-to-v2.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# +# Migrate a v1 Plutainer volume to the v2 layout. +# +# v1: app/{gamefiles,plutonium,logs} +# v2: app/{configs,runtime/{gamefiles,plutonium},logs,.plutainer-version} +# +# Run via: +# docker run --rm \ +# -v :/home/plutainer/app \ +# --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ +# ghcr.io/ayymoss/plutainer:main +# +# Add --dry-run as the first argument to preview without making changes. +# +# Effects: +# 1. mkdir runtime/, configs/ +# 2. mv app/gamefiles -> app/runtime/gamefiles (if present) +# 3. mv app/plutonium -> app/runtime/plutonium (if present) +# 4. For each known engine config dir, move every top-level *.cfg into +# app/configs/ and leave a relative symlink in its place. +# 5. Wipe app/logs/* (stale symlinks; log-watcher re-creates on next start). +# 6. Write app/.plutainer-version=2 +# +set -euo pipefail + +APP_DIR="/home/plutainer/app" +CONFIGS_DIR="$APP_DIR/configs" +RUNTIME_DIR="$APP_DIR/runtime" +GAMEFILES_DIR="$RUNTIME_DIR/gamefiles" +PLUTONIUM_DIR="$RUNTIME_DIR/plutonium" +MARKER="$APP_DIR/.plutainer-version" + +DRY_RUN=false +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=true + echo "[DRY-RUN] no changes will be made" +fi + +run() { + if [[ "$DRY_RUN" == "true" ]]; then + echo " + $*" + else + echo " + $*" + "$@" + fi +} + +# --- Sanity checks --- +if [[ ! -d "$APP_DIR" ]]; then + echo "[ERROR] $APP_DIR is not mounted." >&2 + exit 1 +fi + +if [[ -f "$MARKER" ]]; then + v="$(cat "$MARKER" 2>/dev/null || echo "")" + if [[ "$v" == "2" ]]; then + echo "[INFO] Volume is already v2 (marker present). Nothing to do." + exit 0 + else + echo "[ERROR] Unexpected version marker: '$v'. Refusing to migrate." >&2 + exit 1 + fi +fi + +if [[ ! -d "$APP_DIR/gamefiles" && ! -d "$APP_DIR/plutonium" ]]; then + echo "[INFO] No v1 markers found and no version file — treating as fresh volume." + run mkdir -p "$CONFIGS_DIR" "$APP_DIR/logs" "$RUNTIME_DIR" + if [[ "$DRY_RUN" != "true" ]]; then echo 2 > "$MARKER"; else echo " + echo 2 > $MARKER"; fi + echo "[OK] Initialised fresh v2 volume." + exit 0 +fi + +echo "[INFO] v1 layout detected — beginning migration." +echo + +# --- Step 1: create runtime/, configs/ --- +echo "--- Creating new directories ---" +run mkdir -p "$RUNTIME_DIR" "$CONFIGS_DIR" "$APP_DIR/logs" +echo + +# --- Step 2: move gamefiles, plutonium under runtime/ --- +echo "--- Relocating gamefiles/ and plutonium/ under runtime/ ---" +if [[ -d "$APP_DIR/gamefiles" ]]; then + run mv "$APP_DIR/gamefiles" "$GAMEFILES_DIR" +fi +if [[ -d "$APP_DIR/plutonium" ]]; then + run mv "$APP_DIR/plutonium" "$PLUTONIUM_DIR" +fi +echo + +# --- Step 3: lift top-level *.cfg from engine config dirs into configs/ --- +echo "--- Lifting top-level *.cfg files into configs/ ---" +# Known engine config dirs in v2 paths (after the move above). +ENGINE_DIRS=( + "$GAMEFILES_DIR/main" # plutonium t4 + "$GAMEFILES_DIR/admin" # plutonium iw5 + "$GAMEFILES_DIR/userraw" # iw4x + "$GAMEFILES_DIR/zone" # alterware t7x + "$PLUTONIUM_DIR/storage/t5" # plutonium t5 + "$PLUTONIUM_DIR/storage/t6" # plutonium t6 +) + +lift_cfg() { + local src="$1" base dest rel + base="$(basename "$src")" + dest="$CONFIGS_DIR/$base" + if [[ -e "$dest" ]]; then + echo " [SKIP] $base already exists in configs/ — leaving $src untouched" + return 0 + fi + run mv "$src" "$dest" + if [[ "$DRY_RUN" == "true" ]]; then + echo " + ln -sfn ..//$base $src" + else + rel=$(realpath --relative-to="$(dirname "$src")" "$dest") + ln -sfn "$rel" "$src" + echo " + ln -sfn $rel $src" + fi +} + +shopt -s nullglob +for d in "${ENGINE_DIRS[@]}"; do + [[ -d "$d" ]] || continue + found_any=false + for f in "$d"/*.cfg; do + [[ -f "$f" && ! -L "$f" ]] || continue + if [[ "$found_any" == "false" ]]; then + echo " in $d:" + found_any=true + fi + lift_cfg "$f" + done +done +shopt -u nullglob +echo + +# --- Step 4: clear app/logs/ stale entries --- +echo "--- Clearing stale entries in logs/ (log-watcher will repopulate) ---" +if compgen -G "$APP_DIR/logs/*" > /dev/null; then + run find "$APP_DIR/logs" -mindepth 1 -maxdepth 1 -exec rm -rf {} + +fi +echo + +# --- Step 5: write marker --- +echo "--- Writing version marker ---" +if [[ "$DRY_RUN" == "true" ]]; then + echo " + echo 2 > $MARKER" +else + echo 2 > "$MARKER" + echo " + wrote $MARKER" +fi +echo + +if [[ "$DRY_RUN" == "true" ]]; then + echo "[DRY-RUN] No changes made. Re-run without --dry-run to apply." +else + echo "[OK] Migration complete. You can now start the v2 container." +fi diff --git a/scripts/plutoentry.sh b/scripts/plutoentry.sh index 87aafcd..4988726 100644 --- a/scripts/plutoentry.sh +++ b/scripts/plutoentry.sh @@ -1,36 +1,45 @@ #!/bin/bash # -# This entrypoint script is responsible for validating the container's -# environment, setting sensible defaults, and launching the specified -# Plutonium game server. +# Validate environment, prepare the game-files tree and configs/ symlinks, +# update Plutonium binaries, then launch the requested Plutonium server. # +# Assumes entrypoint.sh has already run shim_env_vars + detect_game_type + +# check_volume_version. We still re-source so this script is usable standalone. +# +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/game-config.sh" -# --- Step 1: Update Plutonium Files --- -BASE_GAME=${PLUTO_GAME%??} -SOURCE_DIR="/home/plutainer/gamefiles" -DEST_DIR="/home/plutainer/app/gamefiles" +detect_game_type || exit 1 +check_volume_version || exit 1 +resolve_engine_config_dir || exit 1 + +SOURCE_DIR="$PLUTAINER_SOURCE_DIR" +DEST_DIR="$PLUTAINER_GAMEFILES_DIR" mkdir -p "$DEST_DIR" + +# --- Step 1: Link Game Files --- case "$BASE_GAME" in iw5) echo "Linking files for iw5 (Modern Warfare 3)..." - ln -sf "$SOURCE_DIR"/{main,miles,zone,binkw32.dll,localization.txt,mss32.dll} "$DEST_DIR"/ + link_files "$SOURCE_DIR" "$DEST_DIR" main miles zone binkw32.dll localization.txt mss32.dll ;; t4) echo "Linking files for t4 (World at War)..." - ln -sf "$SOURCE_DIR"/{zone,binkw32.dll,localization.txt,cod.bmp,codlogo.bmp} "$DEST_DIR"/ + link_files "$SOURCE_DIR" "$DEST_DIR" zone binkw32.dll localization.txt cod.bmp codlogo.bmp mkdir -p "$DEST_DIR/main" - ln -sf "$SOURCE_DIR"/main/{iw_00.iwd,iw_14.iwd,iw_21.iwd,iw_22.iwd,iw_24.iwd,iw_26.iwd,localized_english_iw00.iwd,localized_english_iw04.iwd} "$DEST_DIR"/main/ + link_files "$SOURCE_DIR/main" "$DEST_DIR/main" \ + iw_00.iwd iw_14.iwd iw_21.iwd iw_22.iwd iw_24.iwd iw_26.iwd \ + localized_english_iw00.iwd localized_english_iw04.iwd ;; t5) echo "Linking files for t5 (Black Ops)..." - ln -sf "$SOURCE_DIR"/{main,zone,binkw32.dll,localization.txt} "$DEST_DIR"/ + link_files "$SOURCE_DIR" "$DEST_DIR" main zone binkw32.dll localization.txt ;; t6) echo "Linking files for t6 (Black Ops II)..." - ln -sf "$SOURCE_DIR"/{zone,binkw32.dll,codlogo.bmp} "$DEST_DIR"/ + link_files "$SOURCE_DIR" "$DEST_DIR" zone binkw32.dll codlogo.bmp ;; *) echo "[ERROR] Unknown BASE_GAME value '$BASE_GAME'." >&2 @@ -38,102 +47,96 @@ case "$BASE_GAME" in ;; esac -PLUTO_CDN_INFO_LOC="/home/plutainer/app/plutonium/cdn_info.json" -if [[ ! -f "${PLUTO_CDN_INFO_LOC}" ]]; then - echo "First container run detected. Downloading Plutonium initial files... This may take a few minutes." - /home/plutainer/.plutainer/plutonium-updater --directory /home/plutainer/app/plutonium +# --- Step 2: Update Plutonium --- +mkdir -p "$PLUTAINER_PLUTONIUM_DIR" +PLUTO_CDN_INFO_LOC="$PLUTAINER_PLUTONIUM_DIR/cdn_info.json" +if [[ -f "$PLUTO_CDN_INFO_LOC" && "${PLUTAINER_AUTO_UPDATE:-}" == "false" ]]; then + echo "Skipping Plutonium update because PLUTAINER_AUTO_UPDATE is set to 'false'." else - if [[ "${PLUTO_AUTO_UPDATE}" == "false" ]]; then - echo "Skipping Plutonium update because PLUTO_AUTO_UPDATE is set to 'false'." + if [[ -f "$PLUTO_CDN_INFO_LOC" ]]; then + echo "Checking for Plutonium updates..." else - echo "Checking for Plutonium updates... This may take a few minutes if an update is available." - /home/plutainer/.plutainer/plutonium-updater --directory /home/plutainer/app/plutonium + echo "First container run detected. Downloading Plutonium initial files..." fi + /home/plutainer/.plutainer/plutonium-updater --directory "$PLUTAINER_PLUTONIUM_DIR" fi -cd /home/plutainer/app/plutonium +cd "$PLUTAINER_PLUTONIUM_DIR" -# --- Step 1.5: Seed default configs from bundled community repos --- -# Idempotent: cp -n means existing user files are never overwritten. -# Skip with PLUTO_SKIP_SEED=true. -if [[ "${PLUTO_SKIP_SEED}" != "true" ]]; then +# --- Step 3: Seed default configs from bundled community repos --- +# cfg_root_rel="" because every seed for plutonium has the engine config dir +# AS the seed root. +if [[ "${PLUTAINER_SKIP_SEED:-}" != "true" ]]; then case "$BASE_GAME" in - t4) seed_configs t4 /home/plutainer/app/gamefiles/main ;; - iw5) seed_configs iw5 /home/plutainer/app/gamefiles/admin ;; - t5|t6) seed_configs "$BASE_GAME" /home/plutainer/app/plutonium/storage/"$BASE_GAME" ;; + t4) seed_configs t4 "$PLUTAINER_GAMEFILES_DIR/main" "" ;; + iw5) seed_configs iw5 "$PLUTAINER_GAMEFILES_DIR/admin" "" ;; + t5|t6) seed_configs "$BASE_GAME" "$PLUTAINER_PLUTONIUM_DIR/storage/$BASE_GAME" "" ;; esac fi -# --- Step 2: Validate Required Environment Variables --- +# --- Step 4: Fan-out configs/ → engine config dir symlinks --- +link_configs "$ENGINE_CONFIG_DIR" + +# --- Step 5: Validate Required Environment Variables --- MISSING_VAR=false -INVALID_VAR=false VALID_GAMES="iw5mp t4mp t4sp t5mp t5sp t6mp t6zm" +PLUTAINER_SERVER_NAME="${PLUTAINER_SERVER_NAME:-Plutonium Docker Server}" -PLUTO_SERVER_NAME=${PLUTO_SERVER_NAME:-"Plutonium Docker Server"} - -if [[ -z "${PLUTO_GAME}" ]]; then - echo "[ERROR] The 'PLUTO_GAME' environment variable is not set." >&2 +if [[ ! " ${VALID_GAMES} " =~ " ${PLUTAINER_GAME} " ]]; then + echo "[ERROR] Invalid PLUTAINER_GAME for Plutonium: \"${PLUTAINER_GAME}\". Valid: ${VALID_GAMES}" >&2 MISSING_VAR=true -elif [[ ! " ${VALID_GAMES} " =~ " ${PLUTO_GAME} " ]]; then - echo "[ERROR] Invalid value for 'PLUTO_GAME': \"${PLUTO_GAME}\"." >&2 - INVALID_VAR=true fi -if [[ -z "${PLUTO_SERVER_KEY}" ]]; then - echo "[ERROR] The 'PLUTO_SERVER_KEY' environment variable is not set." >&2 - echo " > You must provide a server key from https://platform.plutonium.pw/serverkeys" >&2 +if [[ -z "${PLUTO_SERVER_KEY:-}" ]]; then + echo "[ERROR] PLUTO_SERVER_KEY is not set." >&2 + echo " > Get a server key from https://platform.plutonium.pw/serverkeys" >&2 MISSING_VAR=true fi -if [[ -z "${PLUTO_CONFIG_FILE}" ]]; then - echo "[ERROR] The 'PLUTO_CONFIG_FILE' environment variable is not set." >&2 - echo " > You must specify the name of the server configuration file (e.g., 'dedicated.cfg')." >&2 +if [[ -z "${PLUTAINER_CONFIG_FILE:-}" ]]; then + echo "[ERROR] PLUTAINER_CONFIG_FILE is not set." >&2 + echo " > Filename of your server config (e.g. 'dedicated.cfg')." >&2 MISSING_VAR=true fi -if [[ "$MISSING_VAR" == "true" || "$INVALID_VAR" == "true" ]]; then +if [[ "$MISSING_VAR" == "true" ]]; then echo "-------------------------------------------------" >&2 - if [[ "$INVALID_VAR" == "true" ]]; then - echo "An invalid value was provided. Valid game modes are: ${VALID_GAMES}" >&2 - fi - echo "One or more configuration errors found. Halting startup." >&2 - echo "Exiting in 10 seconds..." >&2 + echo "Configuration error. Halting startup." >&2 sleep 10 exit 1 fi -# --- Step 3: Set Game-Aware Default Port (If Needed) --- -if [[ -z "${PLUTO_PORT}" ]]; then - echo "Optional PLUTO_PORT is not set, determining default port for ${BASE_GAME}..." +# --- Step 6: Resolve port --- +if [[ -z "${PLUTAINER_PORT:-}" ]]; then + echo "PLUTAINER_PORT not set, using default for ${BASE_GAME}..." resolve_default_port "${BASE_GAME}" || { sleep 10; exit 1; } - PLUTO_PORT="${DEFAULT_PORT}" - echo "Default port set to ${PLUTO_PORT}" + PLUTAINER_PORT="${DEFAULT_PORT}" + echo "Default port set to ${PLUTAINER_PORT}" fi -# --- Step 4: Build Server Command Arguments --- +# --- Step 7: Build Server Command Arguments --- declare -a CMD_ARGS=( - "${PLUTO_GAME}" - /home/plutainer/app/gamefiles + "${PLUTAINER_GAME}" + "$PLUTAINER_GAMEFILES_DIR" -dedicated +set key "${PLUTO_SERVER_KEY}" - +set net_port "${PLUTO_PORT}" + +set net_port "${PLUTAINER_PORT}" ) if [[ "${BASE_GAME}" == "iw5" ]]; then - CMD_ARGS+=(+set sv_config "${PLUTO_CONFIG_FILE}") + CMD_ARGS+=(+set sv_config "${PLUTAINER_CONFIG_FILE}") else - CMD_ARGS+=(+exec "${PLUTO_CONFIG_FILE}") + CMD_ARGS+=(+exec "${PLUTAINER_CONFIG_FILE}") fi -if [[ -n "${PLUTO_MOD}" ]]; then - CMD_ARGS+=(+set fs_game "${PLUTO_MOD}") +if [[ -n "${PLUTAINER_MOD:-}" ]]; then + CMD_ARGS+=(+set fs_game "${PLUTAINER_MOD}") fi -if [[ -n "${PLUTO_MAX_CLIENTS}" ]]; then +if [[ -n "${PLUTO_MAX_CLIENTS:-}" ]]; then CMD_ARGS+=(+set sv_maxclients "${PLUTO_MAX_CLIENTS}") fi - -if [[ -n "${PLUTO_EXTRA_ARGS}" ]]; then - CMD_ARGS+=(${PLUTO_EXTRA_ARGS}) +if [[ -n "${PLUTAINER_EXTRA_ARGS:-}" ]]; then + CMD_ARGS+=(${PLUTAINER_EXTRA_ARGS}) fi if [[ "${BASE_GAME}" == "iw5" ]]; then @@ -142,9 +145,9 @@ else CMD_ARGS+=(+map_rotate) fi -# --- Step 5: Launch the Plutonium Server --- +# --- Step 8: Launch --- /home/plutainer/.plutainer/log-watcher.sh & -echo "Starting Plutonium ${PLUTO_GAME} Server: ${PLUTO_SERVER_NAME}" -echo "EXECUTING: wine bin/plutonium-bootstrapper-win32.exe ${CMD_ARGS[@]}" +echo "Starting Plutonium ${PLUTAINER_GAME} Server: ${PLUTAINER_SERVER_NAME}" +echo "EXECUTING: wine bin/plutonium-bootstrapper-win32.exe ${CMD_ARGS[*]}" exec wine bin/plutonium-bootstrapper-win32.exe "${CMD_ARGS[@]}" From 4ed157df4e3f5b80789efc8be701b6ee9ec11937 Mon Sep 17 00:00:00 2001 From: Amos Date: Wed, 13 May 2026 18:05:46 +0100 Subject: [PATCH 2/9] Fix shim_env_vars + detect_game_type under set -u Indirect expansion ${!var} and bare ${VAR} both error out under set -u when the referenced variable is unset. Add :- default so the strict mode in entry scripts works when canonical PLUTAINER_* vars (or any old-prefix ones) are absent. --- scripts/game-config.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/game-config.sh b/scripts/game-config.sh index f17ca6a..043fc02 100644 --- a/scripts/game-config.sh +++ b/scripts/game-config.sh @@ -31,10 +31,10 @@ shim_env_vars() { local suffix prefix old new for suffix in GAME CONFIG_FILE PORT SERVER_NAME MOD AUTO_UPDATE HEALTHCHECK SKIP_SEED EXTRA_ARGS; do new="PLUTAINER_$suffix" - [[ -n "${!new}" ]] && continue + [[ -n "${!new:-}" ]] && continue for prefix in PLUTO IW4X ALTER; do old="${prefix}_${suffix}" - if [[ -n "${!old}" ]]; then + if [[ -n "${!old:-}" ]]; then printf -v "$new" '%s' "${!old}" export "$new" echo "[DEPRECATED] ${old} is renamed to ${new}; old name still accepted for now." >&2 @@ -60,7 +60,7 @@ derive_family() { detect_game_type() { shim_env_vars - if [[ -z "${PLUTAINER_GAME}" ]]; then + if [[ -z "${PLUTAINER_GAME:-}" ]]; then echo "[ERROR] No game specified. Set PLUTAINER_GAME (e.g. t6zm, iw4x, t7x)." >&2 return 1 fi @@ -77,9 +77,9 @@ detect_game_type() { alterware) BASE_GAME="${GAME_NAME}" ;; esac - CONFIG_FILE="${PLUTAINER_CONFIG_FILE}" - CUSTOM_PORT="${PLUTAINER_PORT}" - HEALTHCHECK_FLAG="${PLUTAINER_HEALTHCHECK}" + CONFIG_FILE="${PLUTAINER_CONFIG_FILE:-}" + CUSTOM_PORT="${PLUTAINER_PORT:-}" + HEALTHCHECK_FLAG="${PLUTAINER_HEALTHCHECK:-}" } # Set DEFAULT_PORT based on BASE_GAME (or the arg). From 7f966672ea101be2a69c16ad0d1e74ee7a8dffff Mon Sep 17 00:00:00 2001 From: Amos Date: Wed, 13 May 2026 18:23:40 +0100 Subject: [PATCH 3/9] CI: build :v2 tag from v2-layout branch, keep separate from :latest Workflow now triggers on pushes to main and v2-layout. Tag rules: - :latest only when is_default_branch (= main). - :v2 only when github.ref == refs/heads/v2-layout. These conditions are mutually exclusive, so v2 builds never touch :latest and main builds never touch :v2. Both branches still get a :sha- tag. Doc references updated from :main to :v2. --- .github/workflows/docker-publish.yml | 3 ++- CLAUDE.md | 9 +++++++-- EXAMPLE-docker-compose.yml | 18 +++++++++--------- README.md | 8 ++++---- scripts/game-config.sh | 2 +- scripts/migrate-v1-to-v2.sh | 2 +- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2dae91f..c769d46 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and Push Docker Image on: push: - branches: [ "main" ] + branches: [ "main", "v2-layout" ] paths-ignore: - 'README.md' - 'LICENSE' @@ -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/v2-layout' }} type=ref,event=tag type=ref,event=pr type=sha diff --git a/CLAUDE.md b/CLAUDE.md index 0dc7ffd..dbdf34b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,13 @@ There are no automated tests or linters. The CI pipeline (`.github/workflows/doc ## Tags -- `ghcr.io/ayymoss/plutainer:main` — current v2 layout. Use this for new deployments. -- `ghcr.io/ayymoss/plutainer:latest` — deprecated v1 layout. No further updates. +- `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-`. Branches stay completely separated. ## Architecture diff --git a/EXAMPLE-docker-compose.yml b/EXAMPLE-docker-compose.yml index be2990c..b92ad7d 100644 --- a/EXAMPLE-docker-compose.yml +++ b/EXAMPLE-docker-compose.yml @@ -1,6 +1,6 @@ services: iw4xmp-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: iw4xmp-1 restart: unless-stopped ports: @@ -18,7 +18,7 @@ services: - games-net t6zm-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: t6zm-1 hostname: t6zm-1 restart: unless-stopped @@ -36,7 +36,7 @@ services: - games-net t6mp-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: t6mp-1 hostname: t6mp-1 restart: unless-stopped @@ -54,7 +54,7 @@ services: - games-net t4sp-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: t4sp-1 hostname: t4sp-1 restart: unless-stopped @@ -72,7 +72,7 @@ services: - games-net t4mp-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: t4mp-1 hostname: t4mp-1 restart: unless-stopped @@ -90,7 +90,7 @@ services: - games-net t5mp-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: t5mp-1 hostname: t5mp-1 restart: unless-stopped @@ -109,7 +109,7 @@ services: - games-net t5sp-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: t5sp-1 hostname: t5sp-1 restart: unless-stopped @@ -129,7 +129,7 @@ services: - games-net iw5mp-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: iw5mp-1 hostname: iw5mp-1 restart: unless-stopped @@ -147,7 +147,7 @@ services: - games-net t7x-zm-1: - image: ghcr.io/ayymoss/plutainer:main + image: ghcr.io/ayymoss/plutainer:v2 container_name: t7x-zm-1 restart: unless-stopped ports: diff --git a/README.md b/README.md index 177a59d..7c7fae6 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ This repository contains the necessary files to build and run dedicated game servers for Plutonium, IW4x, and Alterware using Docker. The image is designed to be flexible and configurable through environment variables. -The container is available on GitHub Container Registry: `ghcr.io/ayymoss/plutainer:main` +The container is available on GitHub Container Registry: `ghcr.io/ayymoss/plutainer:v2` -> **Tag note (v2):** The `main` tag tracks the new v2 volume layout and the unified `PLUTAINER_*` environment variables. The legacy `latest` tag is deprecated and will not receive further updates. If you are migrating an existing deployment, see [Upgrading from v1](#upgrading-from-v1). +> **Tag note (v2):** The `v2` tag tracks the new v2 volume layout and the unified `PLUTAINER_*` environment variables. It is built from the `v2-layout` branch and is intentionally separate from `latest`. The `latest` tag continues to point at the older v1 image (legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` env vars, flat `app/gamefiles/` + `app/plutonium/` layout) and is deprecated — no further updates. If you are migrating an existing deployment, see [Upgrading from v1](#upgrading-from-v1). Opt in by changing your `image:` line to `ghcr.io/ayymoss/plutainer:v2`. ## Overview @@ -133,7 +133,7 @@ Run the bundled migration tool once per volume: docker run --rm \ -v :/home/plutainer/app \ --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ - ghcr.io/ayymoss/plutainer:main + ghcr.io/ayymoss/plutainer:v2 ``` Replace `` with the path bound to `/home/plutainer/app` in your compose file (e.g. `./t6zm-1`). The tool: @@ -151,7 +151,7 @@ Add `--dry-run` after the entrypoint to preview without modifying anything: docker run --rm \ -v :/home/plutainer/app \ --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ - ghcr.io/ayymoss/plutainer:main --dry-run + ghcr.io/ayymoss/plutainer:v2 --dry-run ``` If you also have IW4MAdmin sidecar mounts pointing at log paths like `./t6zm-1/plutonium/storage/...`, update them to `./t6zm-1/runtime/plutonium/storage/...` — or better, switch to the stable [log symlink directory](#log-symlinks). diff --git a/scripts/game-config.sh b/scripts/game-config.sh index 043fc02..2028a8a 100644 --- a/scripts/game-config.sh +++ b/scripts/game-config.sh @@ -241,7 +241,7 @@ tool against your bind mount: docker run --rm \ -v :/home/plutainer/app \ --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ - ghcr.io/ayymoss/plutainer:main + ghcr.io/ayymoss/plutainer:v2 (For a docker-compose deployment, is the path bound to /home/plutainer/app — e.g. ./t6zm-1) diff --git a/scripts/migrate-v1-to-v2.sh b/scripts/migrate-v1-to-v2.sh index b95c1b4..f6c4175 100644 --- a/scripts/migrate-v1-to-v2.sh +++ b/scripts/migrate-v1-to-v2.sh @@ -9,7 +9,7 @@ # docker run --rm \ # -v :/home/plutainer/app \ # --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ -# ghcr.io/ayymoss/plutainer:main +# ghcr.io/ayymoss/plutainer:v2 # # Add --dry-run as the first argument to preview without making changes. # From b9f353e4f081b6f621671404bdd023a47d07ac87 Mon Sep 17 00:00:00 2001 From: Amos Date: Wed, 13 May 2026 19:55:21 +0100 Subject: [PATCH 4/9] v2: drop env-var shim; add raw-configs mode, auto-lift, hold/throttle, mod fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking (only on :v2 — :latest unaffected): * shim_env_vars removed. PLUTO_*/IW4X_*/ALTER_* prefix env vars no longer recognised. PLUTAINER_* is the only accepted form (plus the three unique vars PLUTO_SERVER_KEY, PLUTO_MAX_CLIENTS, IW4X_NET_LOG_IP). New: * PLUTAINER_USE_RAW_CONFIGS=true opts out of the configs/ symlink system; engine path becomes the SOT directly. Default off. * ensure_config_present: auto-lifts CONFIG_FILE from engine path to configs/ if found as a real file there; refuses startup with case-insensitive find-iname hint if absent everywhere. * resolve_mod_config_dir + link_configs variadic: when PLUTAINER_MOD is set, cfg symlinks fan out into BOTH the base engine config dir AND the mod-scoped dir (plutonium fs_game / iw4x mod path). Alterware skipped (Workshop ID, not a path). * link_configs refuses to overwrite a real file at engine path; warns instead. Prevents the silent-replace failure mode. * hold_indefinitely: on startup validation failure, exec sleep infinity so the container stays Up and docker doesn't loop restart it. User fixes the config and runs docker restart. * launch_game: 30s sleep after wine exits before container exits, so restart policies (no min-delay knob in compose) throttle to ~1 restart per 30s on real crashes. * Improved extract_rcon_password: handles single-quoted and unquoted forms; on failure emits a structured WARN telling the user the accepted forms and not to set rcon_password via PLUTAINER_EXTRA_ARGS. Doesn't block startup. Docs updated (README + CLAUDE.md): removed deprecated-prefix table, documented PLUTAINER_USE_RAW_CONFIGS, auto-lift, refuse behavior, restart throttle, and the rcon parser accepted forms. --- CLAUDE.md | 41 ++++--- README.md | 65 +++++++---- scripts/alterentry.sh | 39 +++---- scripts/entrypoint.sh | 18 +-- scripts/game-config.sh | 252 +++++++++++++++++++++++++++++++---------- scripts/iw4xentry.sh | 36 +++--- scripts/plutoentry.sh | 52 +++------ 7 files changed, 315 insertions(+), 188 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dbdf34b..cb0e98e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,25 +41,29 @@ CI logic in `.github/workflows/docker-publish.yml`: 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`, applies `shim_env_vars` (back-compat for old prefixed names), calls `detect_game_type` to derive `GAME_TYPE`/`BASE_GAME`/etc from `PLUTAINER_GAME`, runs `check_volume_version` (refuses v1 volumes), then `exec`s the family-specific entry script. +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 `app/configs/`, fans out symlinks from `app/configs/` to the engine's expected config dir, validates env vars, then `exec`s `wine bin/plutonium-bootstrapper-win32.exe`. +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, validates env vars, `exec`s `wine iw4x.exe`. No seed bundle (IW4x has no community config repo configured). +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, validates env vars, starts `Xvfb` (T7x requires a display), `exec`s `wine t7x.exe`. +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). -5. **`game-config.sh`** — Shared shell library sourced by all other scripts. Provides: +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`. - - `shim_env_vars`: maps deprecated `PLUTO_*`/`IW4X_*`/`ALTER_*` names onto unified `PLUTAINER_*` with a `[DEPRECATED]` warning. + - `hold_indefinitely `: 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 ...`: 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 `: returns `plutonium`/`iw4x`/`alterware`. - - `detect_game_type`: validates `PLUTAINER_GAME`, sets `GAME_TYPE`/`GAME_NAME`/`BASE_GAME`/`CONFIG_FILE`/`CUSTOM_PORT`/`HEALTHCHECK_FLAG`. - - `resolve_default_port`, `resolve_engine_config_dir`, `resolve_config_path`. - - `link_files ...`: existence-guarded symlink helper; replaces unsafe `ln -sf src/{a,b,c} dest/` bash brace expansion that creates dangling symlinks when source files are missing. - - `seed_configs `: walks bundled seed, lifts top-level `*.cfg` files inside `cfg-root-rel` into `app/configs/`, places everything else under `asset-root`. Idempotent (`cp -n`). - - `link_configs `: fans out symlinks from every `app/configs/*.cfg` into the engine's expected dir using relative paths. Reaps dangling cfg symlinks. + - `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 ...`: existence-guarded symlink helper; replaces unsafe `ln -sf src/{a,b,c} dest/` bash brace expansion. + - `seed_configs `: 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-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`. + - `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`. 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`. @@ -100,8 +104,17 @@ For Plutonium, `BASE_GAME` is derived by stripping the last two chars from `PLUT - **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). -## Backward Compatibility +## Compatibility surface -Old prefixed env vars (`PLUTO_*`, `IW4X_*`, `ALTER_*`) still work — `shim_env_vars` in `game-config.sh` copies them onto the canonical `PLUTAINER_*` names and emits a `[DEPRECATED]` warning. Three names stay as-is because they only apply to a single engine family: `PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, `IW4X_NET_LOG_IP`. +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 + +Two distinct failure modes: + +- **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 `. +- **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. + +`STOPSIGNAL` is `SIGKILL`, so neither path interferes with `docker stop` — that's instant by design. diff --git a/README.md b/README.md index 7c7fae6..a56acc5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This repository contains the necessary files to build and run dedicated game ser The container is available on GitHub Container Registry: `ghcr.io/ayymoss/plutainer:v2` -> **Tag note (v2):** The `v2` tag tracks the new v2 volume layout and the unified `PLUTAINER_*` environment variables. It is built from the `v2-layout` branch and is intentionally separate from `latest`. The `latest` tag continues to point at the older v1 image (legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` env vars, flat `app/gamefiles/` + `app/plutonium/` layout) and is deprecated — no further updates. If you are migrating an existing deployment, see [Upgrading from v1](#upgrading-from-v1). Opt in by changing your `image:` line to `ghcr.io/ayymoss/plutainer:v2`. +> **Tag note (v2):** The `v2` tag is a **breaking change** from `latest`. It introduces a new volume layout, unified `PLUTAINER_*` environment variables (the legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` prefixes are **not accepted**), and stricter startup validation. It is built from the `v2-layout` branch and is intentionally separate from `latest` — the two tags do not share builds. The `latest` tag continues to point at the older v1 image (legacy env vars, flat `app/gamefiles/` + `app/plutonium/` layout) and is deprecated; no further updates. If you are migrating an existing deployment, see [Upgrading from v1](#upgrading-from-v1). Opt in by changing your `image:` line to `ghcr.io/ayymoss/plutainer:v2`. ## Overview @@ -50,6 +50,7 @@ The container is configured entirely through environment variables. You must set | `PLUTAINER_HEALTHCHECK` | Set to `"false"` to disable the RCON health check. | `true` | | `PLUTAINER_SKIP_SEED` | Set to `"true"` to skip first-run [config seeding](#bundled-config-seeds). | `false` | | `PLUTAINER_EXTRA_ARGS` | Extra arguments appended to the launch command. | | +| `PLUTAINER_USE_RAW_CONFIGS` | Set to `"true"` to put cfg files directly in the engine path under `app/runtime/...` and skip the `app/configs/` symlink system. See [Raw Configs Mode](#raw-configs-mode). | `false` | | `PLUTAINER_LOG_SYMLINKS` | Set to `"false"` to disable the [log symlink watcher](#log-symlinks). | `true` | | `PLUTAINER_LOG_POLL_INTERVAL` | Seconds between log watcher polls. | `2` | @@ -73,23 +74,7 @@ These cannot be unified because they only apply to a single engine family: | t6 | 4976 | | t7x | 27017 | -#### Backward compatibility (deprecated old-prefix names) - -The old `PLUTO_*`, `IW4X_*`, and `ALTER_*` prefixed environment variables are still accepted but will emit a `[DEPRECATED]` warning at startup. They map onto the unified names below: - -| Old (deprecated) | New canonical | -| --- | --- | -| `PLUTO_GAME`, `IW4X_GAME`, `ALTER_GAME` | `PLUTAINER_GAME` | -| `PLUTO_CONFIG_FILE`, `IW4X_CONFIG_FILE`, `ALTER_CONFIG_FILE` | `PLUTAINER_CONFIG_FILE` | -| `PLUTO_PORT`, `IW4X_PORT`, `ALTER_PORT` | `PLUTAINER_PORT` | -| `PLUTO_SERVER_NAME`, `IW4X_SERVER_NAME`, `ALTER_SERVER_NAME` | `PLUTAINER_SERVER_NAME` | -| `PLUTO_MOD`, `IW4X_MOD`, `ALTER_MOD` | `PLUTAINER_MOD` | -| `PLUTO_AUTO_UPDATE`, `IW4X_AUTO_UPDATE`, `ALTER_AUTO_UPDATE` | `PLUTAINER_AUTO_UPDATE` | -| `PLUTO_HEALTHCHECK`, `IW4X_HEALTHCHECK`, `ALTER_HEALTHCHECK` | `PLUTAINER_HEALTHCHECK` | -| `PLUTO_SKIP_SEED`, `ALTER_SKIP_SEED` | `PLUTAINER_SKIP_SEED` | -| `PLUTO_EXTRA_ARGS`, `IW4X_EXTRA_ARGS`, `ALTER_EXTRA_ARGS` | `PLUTAINER_EXTRA_ARGS` | - -`PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, and `IW4X_NET_LOG_IP` keep their original names — they are not duplicates across families, so they don't need unification. +> The legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` prefixed env vars from the `:latest` (v1) image are **not accepted** on `:v2`. Use the unified `PLUTAINER_*` names above. The only old names that remain are `PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, and `IW4X_NET_LOG_IP` — they are single-family vars and never had a unified form. *** @@ -119,7 +104,28 @@ app/ Example: for a T6 server with `PLUTAINER_CONFIG_FILE=dedicated_zm.cfg`, you edit `app/configs/dedicated_zm.cfg`, and the container symlinks `app/runtime/plutonium/storage/t6/dedicated_zm.cfg → ../../../../configs/dedicated_zm.cfg`. -Nested configs (e.g. mod-specific cfgs inside `mods//`) stay at their engine path under `app/runtime/` and are not lifted to `configs/`. You can still edit them there. +If you set `PLUTAINER_MOD`, the same cfg also gets symlinked into the mod's config dir (e.g. `app/runtime/plutonium/storage/t6//dedicated_zm.cfg`), so the engine finds it whether it looks in the base dir or the mod-scoped one. + +Nested configs (e.g. cfg files referenced by mods using subdirectories) stay at their engine path under `app/runtime/` and are not lifted to `configs/`. You can still edit them there. + +**Auto-lift:** if you set `PLUTAINER_CONFIG_FILE=dedicated.cfg` but the file is at `app/runtime/.../dedicated.cfg` (as a real file, not symlink) rather than `app/configs/dedicated.cfg`, the container moves it into `app/configs/` on next start and the symlink fan-out picks it up. One-time fix, no manual migration. + +**Filename mismatch:** if the configured file doesn't exist anywhere, the container refuses to start with a clear error and (if there's a case-only mismatch like `Server.cfg` vs `server.cfg`) tells you which case-insensitive match it found. Filenames remain case-sensitive — the container won't auto-rename. + +*** + +### Raw Configs Mode + +Set `PLUTAINER_USE_RAW_CONFIGS=true` to opt out of the `app/configs/` SOT model. With this on: + +- The engine config dir under `app/runtime/...` becomes the source of truth. +- `app/configs/` is left untouched (whatever's there is ignored). +- No symlinks are placed; cfg files live where the game reads them. +- Seed configs go directly into the engine dir. + +Use this when you want the v1 editing experience inside the v2 directory layout (e.g. tooling on your host expects to find `t6zm-1/runtime/plutonium/storage/t6/dedicated.cfg` as a real file). Default-off so most users get the "edit in one folder" affordance without thinking about it. + +You can toggle this between restarts. Plutainer doesn't migrate files when you flip the flag — that's on you. *** @@ -210,7 +216,26 @@ docker exec rcon-cli status docker exec -i rcon-cli ``` -Your server configuration file must have `rcon_password` set for `rcon-cli` to work. +Your server configuration file must have `rcon_password` set for `rcon-cli` to work. Accepted forms in the cfg: + +``` +set rcon_password "your_password_here" +seta rcon_password 'also_works' +set rcon_password unquoted_also_ok +``` + +Comment-only lines (`// ...`) are ignored. If multiple uncommented `rcon_password` lines exist, the last one wins. If the parser can't find one, the container prints a `[WARN]` at startup but keeps running — healthcheck and rcon-cli are then unavailable until you add it. **Do not** set `rcon_password` via `PLUTAINER_EXTRA_ARGS` — Plutainer cannot read it back from there. + +*** + +### Restart behavior + +Plutainer distinguishes between *configuration errors* (your fault) and *runtime crashes* (the game's fault): + +- **Configuration error** (e.g. missing `PLUTAINER_CONFIG_FILE`, wrong volume version, unparseable `PLUTAINER_GAME`): the container prints the error and then `sleep infinity` to **hold** in the `Up` state. No restart loop. Fix the issue and run `docker restart `. +- **Runtime crash** (wine exits, game segfaults, etc): the container sleeps **30 seconds** after the game process exits, then exits itself. Docker's `restart: unless-stopped` (or your chosen policy) then restarts it. This rate-limits crash loops to ~1 restart per 30s instead of hammering immediately. + +Healthcheck still runs on a held container and will eventually mark it unhealthy — useful signal for orchestration. *** diff --git a/scripts/alterentry.sh b/scripts/alterentry.sh index f16e32d..6b428da 100644 --- a/scripts/alterentry.sh +++ b/scripts/alterentry.sh @@ -8,9 +8,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/game-config.sh" -detect_game_type || exit 1 -check_volume_version || exit 1 -resolve_engine_config_dir || exit 1 +detect_game_type || hold_indefinitely "detect_game_type failed." +check_volume_version || hold_indefinitely "check_volume_version failed." +resolve_engine_config_dir +resolve_mod_config_dir +resolve_config_layout SOURCE_DIR="$PLUTAINER_SOURCE_DIR" DEST_DIR="$PLUTAINER_GAMEFILES_DIR" @@ -42,9 +44,7 @@ if compgen -G "$SOURCE_DIR/zone/*" > /dev/null; then fi # --- Step 2: Download/Update T7x --- -# wget -N uses timestamping: it sends If-Modified-Since and only downloads -# when upstream is newer than the local file. Avoids redownloading on every -# restart. +# wget -N: timestamping. Only downloads when upstream is newer than local. ALTER_EXE_LOC="$DEST_DIR/t7x.exe" if [[ -f "$ALTER_EXE_LOC" && "${PLUTAINER_AUTO_UPDATE:-}" == "false" ]]; then echo "Skipping T7x update because PLUTAINER_AUTO_UPDATE is set to 'false'." @@ -62,39 +62,32 @@ cd "$DEST_DIR" # --- Step 3: Seed default configs from bundled community repo --- # t7x seed bundle has two top-level dirs: `zone/` (configs) and `t7x/` (lobby # scripts). cfg_root_rel="zone" lifts top-level `zone/*.cfg` files into -# app/configs/ flat; everything else stays under runtime/gamefiles/. +# CONFIG_SOT_DIR; everything else stays under runtime/gamefiles/. if [[ "${PLUTAINER_SKIP_SEED:-}" != "true" ]]; then seed_configs t7x "$DEST_DIR" "zone" fi # --- Step 4: Fan-out configs/ → engine config dir --- +# (Alterware MOD is a Steam Workshop ID, not a filesystem path — no MOD dir.) link_configs "$ENGINE_CONFIG_DIR" -# --- Step 5: Validate Required Environment Variables --- -MISSING_VAR=false +# --- Step 5: Validate environment + ensure config file exists --- PLUTAINER_SERVER_NAME="${PLUTAINER_SERVER_NAME:-T7x Docker Server}" if [[ "${PLUTAINER_GAME}" != "t7x" ]]; then - echo "[ERROR] PLUTAINER_GAME must be 't7x' for the Alterware entrypoint." >&2 - MISSING_VAR=true + hold_indefinitely "PLUTAINER_GAME must be 't7x' for the Alterware entrypoint." fi if [[ -z "${PLUTAINER_CONFIG_FILE:-}" ]]; then - echo "[ERROR] PLUTAINER_CONFIG_FILE is not set." >&2 - echo " > Filename of your server config (e.g. 'server_zm.cfg')." >&2 - MISSING_VAR=true + hold_indefinitely "PLUTAINER_CONFIG_FILE is not set. Specify the filename of your server config (e.g. 'server_zm.cfg')." fi - -if [[ "$MISSING_VAR" == "true" ]]; then - echo "-------------------------------------------------" >&2 - echo "Configuration error. Halting startup." >&2 - sleep 10 - exit 1 +if ! ensure_config_present; then + hold_indefinitely "Config file not found. See [ERROR] above." fi # --- Step 6: Resolve port --- if [[ -z "${PLUTAINER_PORT:-}" ]]; then echo "PLUTAINER_PORT not set, using default for t7x..." - resolve_default_port "t7x" || { sleep 10; exit 1; } + resolve_default_port "t7x" || hold_indefinitely "Could not resolve default port." PLUTAINER_PORT="${DEFAULT_PORT}" echo "Default port set to ${PLUTAINER_PORT}" fi @@ -112,7 +105,7 @@ if [[ -n "${PLUTAINER_EXTRA_ARGS:-}" ]]; then CMD_ARGS+=(${PLUTAINER_EXTRA_ARGS}) fi -# --- Step 8: Launch --- +# --- Step 8: Launch (with 30s crash throttle) --- # T7x requires a display even in dedicated/headless mode, so start a virtual # framebuffer for Wine before launching. echo "Starting virtual display..." @@ -124,4 +117,4 @@ sleep 1 echo "Starting T7x Server: ${PLUTAINER_SERVER_NAME}" echo "EXECUTING: wine t7x.exe ${CMD_ARGS[*]}" -exec wine t7x.exe "${CMD_ARGS[@]}" +launch_game wine t7x.exe "${CMD_ARGS[@]}" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index b365794..2ec92ee 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,8 +1,7 @@ #!/bin/bash # -# Top-level entrypoint. Applies backward-compat shims for old prefixed env -# vars, detects the game family, validates the volume version, then delegates -# to the appropriate game-specific entry script. +# Top-level entrypoint. Detects the game family, validates the volume +# version, then delegates to the appropriate game-specific entry script. # set -euo pipefail @@ -24,23 +23,14 @@ echo echo "Brought to you by Ayymoss" echo -# --- Shim old-prefix env vars (PLUTO_/IW4X_/ALTER_) into PLUTAINER_* --- -shim_env_vars - # --- Detect game type from PLUTAINER_GAME --- if ! detect_game_type; then - echo "-------------------------------------------------" >&2 - echo "Set PLUTAINER_GAME to one of: t4mp, t4sp, t5mp, t5sp, t6mp, t6zm, iw5mp, iw4x, t7x" >&2 - echo "Exiting in 10 seconds..." >&2 - sleep 10 - exit 1 + hold_indefinitely "Set PLUTAINER_GAME to one of: t4mp, t4sp, t5mp, t5sp, t6mp, t6zm, iw5mp, iw4x, t7x" fi # --- Validate (or initialise) volume layout --- if ! check_volume_version; then - echo "Exiting in 10 seconds..." >&2 - sleep 10 - exit 1 + hold_indefinitely "Volume layout check failed. See the migration instructions above." fi # --- Dispatch to game-specific entrypoint --- diff --git a/scripts/game-config.sh b/scripts/game-config.sh index 2028a8a..171815d 100644 --- a/scripts/game-config.sh +++ b/scripts/game-config.sh @@ -6,7 +6,7 @@ # Volume layout (v2): # /home/plutainer/app/ # configs/ # User-facing config files (flat). -# # Real files. Engine paths symlink here. +# # Real files unless PLUTAINER_USE_RAW_CONFIGS=true. # logs/ # Stable symlinks to active *.log files # # (maintained by log-watcher.sh). # runtime/ @@ -24,24 +24,33 @@ PLUTAINER_GAMEFILES_DIR="$PLUTAINER_RUNTIME_DIR/gamefiles" PLUTAINER_PLUTONIUM_DIR="$PLUTAINER_RUNTIME_DIR/plutonium" PLUTAINER_SOURCE_DIR="/home/plutainer/gamefiles" -# Map old-prefix env vars (PLUTO_*, IW4X_*, ALTER_*) to canonical PLUTAINER_* -# names. Only sets PLUTAINER_ if it is currently unset. Emits a single -# deprecation warning per old name that's actually being used. -shim_env_vars() { - local suffix prefix old new - for suffix in GAME CONFIG_FILE PORT SERVER_NAME MOD AUTO_UPDATE HEALTHCHECK SKIP_SEED EXTRA_ARGS; do - new="PLUTAINER_$suffix" - [[ -n "${!new:-}" ]] && continue - for prefix in PLUTO IW4X ALTER; do - old="${prefix}_${suffix}" - if [[ -n "${!old:-}" ]]; then - printf -v "$new" '%s' "${!old}" - export "$new" - echo "[DEPRECATED] ${old} is renamed to ${new}; old name still accepted for now." >&2 - break - fi - done - done +# Halt without exiting. Container stays in the "running" state, docker +# restart policies won't fire a loop, healthchecks will eventually mark it +# unhealthy — user fixes config and runs `docker restart`. +hold_indefinitely() { + local msg="${1:-Refusing to start.}" + echo "-------------------------------------------------" >&2 + echo "$msg" >&2 + echo "[INFO] Holding container running (sleep infinity) to prevent a restart loop." >&2 + echo "[INFO] Fix the issue, then run: docker restart " >&2 + exec sleep infinity +} + +# Run the game binary, then sleep 30s before letting the container exit. +# Restart policies (e.g. `restart: unless-stopped`) react to container exit +# but docker compose has no native min-delay knob — the in-script sleep is +# how we throttle real crashes to one restart per ~30s. +# Args: command + its arguments (e.g. wine ...). +launch_game() { + set +e + "$@" + local rc=$? + set -e + + echo "[INFO] Game process exited (rc=$rc)." >&2 + echo "[INFO] Sleeping 30s before container exit to throttle restart." >&2 + sleep 30 + exit "$rc" } # Derive the game family ("plutonium", "iw4x", "alterware") from PLUTAINER_GAME. @@ -56,10 +65,8 @@ derive_family() { } # Populate GAME_TYPE, GAME_NAME, BASE_GAME, CONFIG_FILE, CUSTOM_PORT, -# HEALTHCHECK_FLAG from PLUTAINER_* (applying back-compat shims first). +# HEALTHCHECK_FLAG from PLUTAINER_*. detect_game_type() { - shim_env_vars - if [[ -z "${PLUTAINER_GAME:-}" ]]; then echo "[ERROR] No game specified. Set PLUTAINER_GAME (e.g. t6zm, iw4x, t7x)." >&2 return 1 @@ -99,7 +106,8 @@ resolve_default_port() { } # Set ENGINE_CONFIG_DIR — the directory where the game engine reads cfg files -# from. Entrypoints place symlinks here that point back into PLUTAINER_CONFIGS_DIR. +# from. Entrypoints place symlinks here that point back into CONFIG_SOT_DIR +# (unless PLUTAINER_USE_RAW_CONFIGS is true, in which case engine path IS the SOT). resolve_engine_config_dir() { case "${GAME_TYPE}" in plutonium) @@ -118,10 +126,47 @@ resolve_engine_config_dir() { esac } -# Set CONFIG_PATH to the user-facing real config file in configs/. -# Requires CONFIG_FILE to be set. +# Set MOD_CONFIG_DIR — the dir where the game looks for cfg files inside the +# active mod (fs_game), if PLUTAINER_MOD is set. Empty when no mod or N/A +# (e.g. alterware uses Steam Workshop IDs, not filesystem paths). +resolve_mod_config_dir() { + MOD_CONFIG_DIR="" + [[ -z "${PLUTAINER_MOD:-}" ]] && return 0 + case "$GAME_TYPE" in + plutonium) + case "$BASE_GAME" in + t4|iw5) MOD_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/$PLUTAINER_MOD" ;; + *) MOD_CONFIG_DIR="$PLUTAINER_PLUTONIUM_DIR/storage/$BASE_GAME/$PLUTAINER_MOD" ;; + esac + ;; + iw4x) + MOD_CONFIG_DIR="$PLUTAINER_GAMEFILES_DIR/$PLUTAINER_MOD" + ;; + # alterware: PLUTAINER_MOD is a Steam Workshop ID; no filesystem cfg dir. + esac +} + +# Decide the config source-of-truth. Default: configs/ (symlink fan-out to +# engine dir). With PLUTAINER_USE_RAW_CONFIGS=true: engine dir IS the SOT +# (no symlinks, cfgs live where the game reads them). +# Requires ENGINE_CONFIG_DIR set first (call resolve_engine_config_dir). +# Sets CONFIG_SOT_DIR, ALT_CONFIG_DIR, CONFIG_PATH. +resolve_config_layout() { + if [[ "${PLUTAINER_USE_RAW_CONFIGS:-}" == "true" ]]; then + CONFIG_SOT_DIR="$ENGINE_CONFIG_DIR" + ALT_CONFIG_DIR="$PLUTAINER_CONFIGS_DIR" + else + CONFIG_SOT_DIR="$PLUTAINER_CONFIGS_DIR" + ALT_CONFIG_DIR="$ENGINE_CONFIG_DIR" + fi + CONFIG_PATH="$CONFIG_SOT_DIR/$CONFIG_FILE" +} + +# Convenience for callers (healthcheck.sh, rcon-cli) that only need +# CONFIG_PATH set. Runs the chain end-to-end. resolve_config_path() { - CONFIG_PATH="$PLUTAINER_CONFIGS_DIR/${CONFIG_FILE}" + resolve_engine_config_dir || return 1 + resolve_config_layout } # Symlink specific named entries from a source dir into a destination dir. @@ -145,22 +190,16 @@ link_files() { # Copy bundled community seed configs into the volume on first run. # Strategy: # - Top-level *.cfg files inside the seed's "config root" subdir -# (cfg_root_rel within the seed bundle) are placed flat in app/configs/ -# so the user can edit them all in one directory. -# - Everything else (assets, mod scripts, nested cfgs, etc) is placed under +# (cfg_root_rel within the seed bundle) land in CONFIG_SOT_DIR. +# - Everything else (assets, mod scripts, nested cfgs, etc) lands under # asset_root, preserving the seed's relative path. # Always idempotent: never overwrites a file that already exists. # Args: -# game-key: subdir under .plutainer/seed-configs/ to read from -# asset_root: destination for non-flat-cfg assets (typically runtime/...) -# cfg_root_rel: path within the seed (and within asset_root) where the -# engine reads top-level cfg files. Use "" if the seed root -# IS the engine config dir. seed_configs() { local game="$1" asset_root="$2" cfg_root_rel="${3:-}" local src="/home/plutainer/.plutainer/seed-configs/${game}" [[ -d "$src" ]] || return 0 - mkdir -p "$asset_root" "$PLUTAINER_CONFIGS_DIR" + mkdir -p "$asset_root" "$CONFIG_SOT_DIR" local rel parent dest while IFS= read -r -d '' relpath; do @@ -168,7 +207,7 @@ seed_configs() { parent="$(dirname "$rel")" [[ "$parent" == "." ]] && parent="" if [[ "$rel" == *.cfg && "$parent" == "$cfg_root_rel" ]]; then - dest="$PLUTAINER_CONFIGS_DIR/$(basename "$rel")" + dest="$CONFIG_SOT_DIR/$(basename "$rel")" else dest="$asset_root/$rel" fi @@ -177,32 +216,85 @@ seed_configs() { done < <(cd "$src" && find . -type f -print0) } -# Place a symlink in / for every *.cfg file in -# app/configs/. Symlinks are relative so they resolve the same on host or in -# sidecar containers. Removes dangling cfg symlinks that point into configs/ -# but whose source has been deleted. -# Args: +# For every *.cfg in configs/, place a relative symlink at each provided +# engine dir. Skips dirs that don't exist; warns on real-file collisions +# (refuses to overwrite a non-symlink); reaps cfg symlinks under each engine +# dir whose target no longer exists. +# No-op when PLUTAINER_USE_RAW_CONFIGS is true (engine path IS the SOT). +# Args: [engine_dir2 ...] link_configs() { - local engine_config_dir="$1" + [[ "${PLUTAINER_USE_RAW_CONFIGS:-}" == "true" ]] && return 0 [[ -d "$PLUTAINER_CONFIGS_DIR" ]] || return 0 - mkdir -p "$engine_config_dir" - - local f base link target_rel - for f in "$PLUTAINER_CONFIGS_DIR"/*.cfg; do - [[ -e "$f" ]] || continue - base="$(basename "$f")" - link="$engine_config_dir/$base" - target_rel=$(realpath --relative-to="$engine_config_dir" "$f") - ln -sfn "$target_rel" "$link" - done - for link in "$engine_config_dir"/*.cfg; do - [[ -L "$link" && ! -e "$link" ]] || continue - echo "[link_configs] reaping dangling: $link" >&2 - rm -f "$link" + local engine_dir f base link target_rel + for engine_dir in "$@"; do + [[ -n "$engine_dir" ]] || continue + mkdir -p "$engine_dir" + + # Fan-out: configs/.cfg -> engine_dir/.cfg + for f in "$PLUTAINER_CONFIGS_DIR"/*.cfg; do + [[ -e "$f" ]] || continue + base="$(basename "$f")" + link="$engine_dir/$base" + if [[ -e "$link" && ! -L "$link" ]]; then + echo "[link_configs] WARNING: real file at $link blocks symlink to $f" >&2 + echo " Move or delete the real file if you want it managed via configs/." >&2 + continue + fi + target_rel=$(realpath --relative-to="$engine_dir" "$f") + ln -sfn "$target_rel" "$link" + done + + # Reap: drop cfg symlinks here whose source no longer resolves. + for link in "$engine_dir"/*.cfg; do + [[ -L "$link" && ! -e "$link" ]] || continue + echo "[link_configs] reaping dangling: $link" >&2 + rm -f "$link" + done done } +# Ensure $CONFIG_FILE exists at the SOT location before launch. +# - If at SOT: ok. +# - If at the ALT location as a real (non-symlink) file: move it to SOT, +# log the auto-lift, ok. +# - Otherwise: refuse with a case-sensitive find hint. +# Requires CONFIG_SOT_DIR, ALT_CONFIG_DIR, CONFIG_FILE set. +ensure_config_present() { + local cfg="$CONFIG_FILE" + local sot_path="$CONFIG_SOT_DIR/$cfg" + local alt_path="$ALT_CONFIG_DIR/$cfg" + + if [[ -e "$sot_path" ]]; then + return 0 + fi + + if [[ -f "$alt_path" && ! -L "$alt_path" ]]; then + echo "[INFO] Auto-lift: found $cfg at $ALT_CONFIG_DIR/ — moving to $CONFIG_SOT_DIR/ (v2 SOT)." + mkdir -p "$CONFIG_SOT_DIR" + mv "$alt_path" "$sot_path" + return 0 + fi + + echo "[ERROR] PLUTAINER_CONFIG_FILE='$cfg' but no such file exists." >&2 + echo " Looked at:" >&2 + echo " $sot_path" >&2 + echo " $alt_path" >&2 + local match + match=$(find "$CONFIG_SOT_DIR" "$ALT_CONFIG_DIR" -maxdepth 1 -type f -iname "$cfg" 2>/dev/null | head -1) + if [[ -n "$match" ]]; then + echo " Did you mean: $(basename "$match") ? (filenames are case-sensitive on Linux)" >&2 + else + echo " Available cfgs in $CONFIG_SOT_DIR/:" >&2 + if compgen -G "$CONFIG_SOT_DIR/*.cfg" > /dev/null; then + (cd "$CONFIG_SOT_DIR" && ls -1 *.cfg) | sed 's/^/ /' >&2 + else + echo " (none)" >&2 + fi + fi + return 1 +} + # Check that the mounted app/ volume is v2-shaped. On a fresh volume, # initialise it. On a v1 volume, refuse to start with explicit migration # instructions. On a v2 volume, just ensure the expected dirs exist. @@ -261,16 +353,52 @@ EOF echo "[INFO] Initialised fresh v2 volume at $PLUTAINER_APP_DIR" } -# Extract the RCON password from the user's config file in configs/. -# Sets RCON_PASSWORD. Requires CONFIG_PATH to be set. +_rcon_missing_warning() { + echo "[WARN] Could not parse rcon_password from ${CONFIG_PATH}." >&2 + echo " Healthcheck and rcon-cli will not work until this is set." >&2 + echo " Expected format in your cfg file:" >&2 + echo " set rcon_password \"your_password_here\"" >&2 + echo " Single quotes and unquoted values are accepted; commented (//) lines are ignored." >&2 + echo " Do NOT set rcon_password via PLUTAINER_EXTRA_ARGS — Plutainer cannot read it back." >&2 +} + +# Extract the RCON password from the user's config file. Handles: +# set rcon_password "value" (double-quoted, default) +# seta rcon_password 'value' (single-quoted) +# set rcon_password value (unquoted; value is first token) +# Strips line comments (//...) before searching. Picks the last uncommented +# match, in case the cfg overrides itself. +# Sets RCON_PASSWORD on success; returns 1 + warning on failure. extract_rcon_password() { + RCON_PASSWORD="" if [[ ! -f "${CONFIG_PATH}" ]]; then - echo "[ERROR] Config file not found at ${CONFIG_PATH}" >&2 + echo "[WARN] Config file not found at ${CONFIG_PATH} — cannot extract rcon_password." >&2 return 1 fi - RCON_PASSWORD=$(grep -v '^[[:space:]]*//' "${CONFIG_PATH}" | grep -i 'rcon_password' | sed -n 's/.*"\([^"]*\)".*/\1/p' | tail -1) - if [[ -z "${RCON_PASSWORD}" ]]; then - echo "[ERROR] Could not find 'rcon_password' in ${CONFIG_PATH}" >&2 + + local line + line=$(sed -e 's|//.*$||' "${CONFIG_PATH}" \ + | grep -iE '^[[:space:]]*set[a]?[[:space:]]+rcon_password[[:space:]]+' \ + | tail -1) + + if [[ -z "$line" ]]; then + _rcon_missing_warning + return 1 + fi + + local dq_pat='"([^"]*)"' + local sq_pat="'([^']*)'" + if [[ "$line" =~ $dq_pat ]]; then + RCON_PASSWORD="${BASH_REMATCH[1]}" + elif [[ "$line" =~ $sq_pat ]]; then + RCON_PASSWORD="${BASH_REMATCH[1]}" + else + # Unquoted: token after 'set|seta rcon_password' + RCON_PASSWORD=$(echo "$line" | awk '{print $3}') + fi + + if [[ -z "$RCON_PASSWORD" ]]; then + _rcon_missing_warning return 1 fi } diff --git a/scripts/iw4xentry.sh b/scripts/iw4xentry.sh index 9929f46..c14c9ff 100644 --- a/scripts/iw4xentry.sh +++ b/scripts/iw4xentry.sh @@ -8,9 +8,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/game-config.sh" -detect_game_type || exit 1 -check_volume_version || exit 1 -resolve_engine_config_dir || exit 1 +detect_game_type || hold_indefinitely "detect_game_type failed." +check_volume_version || hold_indefinitely "check_volume_version failed." +resolve_engine_config_dir +resolve_mod_config_dir +resolve_config_layout SOURCE_DIR="$PLUTAINER_SOURCE_DIR" DEST_DIR="$PLUTAINER_GAMEFILES_DIR" @@ -35,35 +37,27 @@ fi cd "$DEST_DIR" -# --- Step 3: Fan-out configs/ → engine config dir --- +# --- Step 3: Fan-out configs/ → engine + mod config dirs --- # No seed_configs call: iw4x has no bundled community seed. -link_configs "$ENGINE_CONFIG_DIR" +link_configs "$ENGINE_CONFIG_DIR" "$MOD_CONFIG_DIR" -# --- Step 4: Validate Required Environment Variables --- -MISSING_VAR=false +# --- Step 4: Validate environment + ensure config file exists --- PLUTAINER_SERVER_NAME="${PLUTAINER_SERVER_NAME:-IW4x Docker Server}" if [[ "${PLUTAINER_GAME}" != "iw4x" ]]; then - echo "[ERROR] PLUTAINER_GAME must be 'iw4x' for the iw4x entrypoint." >&2 - MISSING_VAR=true + hold_indefinitely "PLUTAINER_GAME must be 'iw4x' for the iw4x entrypoint." fi if [[ -z "${PLUTAINER_CONFIG_FILE:-}" ]]; then - echo "[ERROR] PLUTAINER_CONFIG_FILE is not set." >&2 - echo " > Filename of your server config (e.g. 'server.cfg')." >&2 - MISSING_VAR=true + hold_indefinitely "PLUTAINER_CONFIG_FILE is not set. Specify the filename of your server config (e.g. 'server.cfg')." fi - -if [[ "$MISSING_VAR" == "true" ]]; then - echo "-------------------------------------------------" >&2 - echo "Configuration error. Halting startup." >&2 - sleep 10 - exit 1 +if ! ensure_config_present; then + hold_indefinitely "Config file not found. See [ERROR] above." fi # --- Step 5: Resolve port --- if [[ -z "${PLUTAINER_PORT:-}" ]]; then echo "PLUTAINER_PORT not set, using default for iw4x..." - resolve_default_port "iw4x" || { sleep 10; exit 1; } + resolve_default_port "iw4x" || hold_indefinitely "Could not resolve default port." PLUTAINER_PORT="${DEFAULT_PORT}" echo "Default port set to ${PLUTAINER_PORT}" fi @@ -91,9 +85,9 @@ fi CMD_ARGS+=(+map_rotate) -# --- Step 7: Launch --- +# --- Step 7: Launch (with 30s crash throttle) --- /home/plutainer/.plutainer/log-watcher.sh & echo "Starting iw4x Server: ${PLUTAINER_SERVER_NAME}" echo "EXECUTING: wine iw4x.exe ${CMD_ARGS[*]}" -exec wine iw4x.exe "${CMD_ARGS[@]}" +launch_game wine iw4x.exe "${CMD_ARGS[@]}" diff --git a/scripts/plutoentry.sh b/scripts/plutoentry.sh index 4988726..4ee871f 100644 --- a/scripts/plutoentry.sh +++ b/scripts/plutoentry.sh @@ -3,17 +3,16 @@ # Validate environment, prepare the game-files tree and configs/ symlinks, # update Plutonium binaries, then launch the requested Plutonium server. # -# Assumes entrypoint.sh has already run shim_env_vars + detect_game_type + -# check_volume_version. We still re-source so this script is usable standalone. -# set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/game-config.sh" -detect_game_type || exit 1 -check_volume_version || exit 1 -resolve_engine_config_dir || exit 1 +detect_game_type || hold_indefinitely "detect_game_type failed." +check_volume_version || hold_indefinitely "check_volume_version failed." +resolve_engine_config_dir +resolve_mod_config_dir +resolve_config_layout SOURCE_DIR="$PLUTAINER_SOURCE_DIR" DEST_DIR="$PLUTAINER_GAMEFILES_DIR" @@ -42,8 +41,7 @@ case "$BASE_GAME" in link_files "$SOURCE_DIR" "$DEST_DIR" zone binkw32.dll codlogo.bmp ;; *) - echo "[ERROR] Unknown BASE_GAME value '$BASE_GAME'." >&2 - exit 1 + hold_indefinitely "Unknown BASE_GAME value '$BASE_GAME'." ;; esac @@ -64,8 +62,6 @@ fi cd "$PLUTAINER_PLUTONIUM_DIR" # --- Step 3: Seed default configs from bundled community repos --- -# cfg_root_rel="" because every seed for plutonium has the engine config dir -# AS the seed root. if [[ "${PLUTAINER_SKIP_SEED:-}" != "true" ]]; then case "$BASE_GAME" in t4) seed_configs t4 "$PLUTAINER_GAMEFILES_DIR/main" "" ;; @@ -74,42 +70,30 @@ if [[ "${PLUTAINER_SKIP_SEED:-}" != "true" ]]; then esac fi -# --- Step 4: Fan-out configs/ → engine config dir symlinks --- -link_configs "$ENGINE_CONFIG_DIR" +# --- Step 4: Fan-out configs/ → engine + mod config dirs --- +link_configs "$ENGINE_CONFIG_DIR" "$MOD_CONFIG_DIR" -# --- Step 5: Validate Required Environment Variables --- -MISSING_VAR=false -VALID_GAMES="iw5mp t4mp t4sp t5mp t5sp t6mp t6zm" +# --- Step 5: Validate environment + ensure config file exists --- PLUTAINER_SERVER_NAME="${PLUTAINER_SERVER_NAME:-Plutonium Docker Server}" +VALID_GAMES="iw5mp t4mp t4sp t5mp t5sp t6mp t6zm" if [[ ! " ${VALID_GAMES} " =~ " ${PLUTAINER_GAME} " ]]; then - echo "[ERROR] Invalid PLUTAINER_GAME for Plutonium: \"${PLUTAINER_GAME}\". Valid: ${VALID_GAMES}" >&2 - MISSING_VAR=true + hold_indefinitely "Invalid PLUTAINER_GAME for Plutonium: \"${PLUTAINER_GAME}\". Valid: ${VALID_GAMES}" fi - if [[ -z "${PLUTO_SERVER_KEY:-}" ]]; then - echo "[ERROR] PLUTO_SERVER_KEY is not set." >&2 - echo " > Get a server key from https://platform.plutonium.pw/serverkeys" >&2 - MISSING_VAR=true + hold_indefinitely "PLUTO_SERVER_KEY is not set. Get a server key from https://platform.plutonium.pw/serverkeys" fi - if [[ -z "${PLUTAINER_CONFIG_FILE:-}" ]]; then - echo "[ERROR] PLUTAINER_CONFIG_FILE is not set." >&2 - echo " > Filename of your server config (e.g. 'dedicated.cfg')." >&2 - MISSING_VAR=true + hold_indefinitely "PLUTAINER_CONFIG_FILE is not set. Specify the filename of your server config (e.g. 'dedicated.cfg')." fi - -if [[ "$MISSING_VAR" == "true" ]]; then - echo "-------------------------------------------------" >&2 - echo "Configuration error. Halting startup." >&2 - sleep 10 - exit 1 +if ! ensure_config_present; then + hold_indefinitely "Config file not found. See [ERROR] above." fi # --- Step 6: Resolve port --- if [[ -z "${PLUTAINER_PORT:-}" ]]; then echo "PLUTAINER_PORT not set, using default for ${BASE_GAME}..." - resolve_default_port "${BASE_GAME}" || { sleep 10; exit 1; } + resolve_default_port "${BASE_GAME}" || hold_indefinitely "Could not resolve default port." PLUTAINER_PORT="${DEFAULT_PORT}" echo "Default port set to ${PLUTAINER_PORT}" fi @@ -145,9 +129,9 @@ else CMD_ARGS+=(+map_rotate) fi -# --- Step 8: Launch --- +# --- Step 8: Launch (with 30s crash throttle) --- /home/plutainer/.plutainer/log-watcher.sh & echo "Starting Plutonium ${PLUTAINER_GAME} Server: ${PLUTAINER_SERVER_NAME}" echo "EXECUTING: wine bin/plutonium-bootstrapper-win32.exe ${CMD_ARGS[*]}" -exec wine bin/plutonium-bootstrapper-win32.exe "${CMD_ARGS[@]}" +launch_game wine bin/plutonium-bootstrapper-win32.exe "${CMD_ARGS[@]}" From 9a256a678a216826890285d28c90e2a65b3e8858 Mon Sep 17 00:00:00 2001 From: Amos Date: Wed, 13 May 2026 19:57:40 +0100 Subject: [PATCH 5/9] Split auto-lift from ensure_config_present so seed can't pre-empt user file Original order: seed_configs (cp -n) before ensure_config_present meant a user's real cfg at engine path got bypassed because seed had already filled configs/ with a default basename, satisfying the SOT check. Now auto_lift_user_config runs before seed: if user has a real (non-symlink) cfg at engine path, mv -f it to configs/ unconditionally. Then seed gap-fills (cp -n preserves the user file). Then link_configs fans out. Then ensure_config_present only does the presence-or-refuse check. Three callers updated: alterentry, plutoentry, iw4xentry. --- scripts/alterentry.sh | 5 ++++- scripts/game-config.sh | 39 +++++++++++++++++++++++++-------------- scripts/iw4xentry.sh | 5 ++++- scripts/plutoentry.sh | 5 ++++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/scripts/alterentry.sh b/scripts/alterentry.sh index 6b428da..181a582 100644 --- a/scripts/alterentry.sh +++ b/scripts/alterentry.sh @@ -59,7 +59,10 @@ fi cd "$DEST_DIR" -# --- Step 3: Seed default configs from bundled community repo --- +# --- Step 3a: Auto-lift any user-placed real cfg from engine path --- +auto_lift_user_config + +# --- Step 3b: Seed default configs from bundled community repo --- # t7x seed bundle has two top-level dirs: `zone/` (configs) and `t7x/` (lobby # scripts). cfg_root_rel="zone" lifts top-level `zone/*.cfg` files into # CONFIG_SOT_DIR; everything else stays under runtime/gamefiles/. diff --git a/scripts/game-config.sh b/scripts/game-config.sh index 171815d..f2e3302 100644 --- a/scripts/game-config.sh +++ b/scripts/game-config.sh @@ -254,32 +254,43 @@ link_configs() { done } -# Ensure $CONFIG_FILE exists at the SOT location before launch. -# - If at SOT: ok. -# - If at the ALT location as a real (non-symlink) file: move it to SOT, -# log the auto-lift, ok. -# - Otherwise: refuse with a case-sensitive find hint. +# If the user has placed a REAL (non-symlink) cfg file at the engine path, +# treat that as authoritative and move it into the SOT location. Engine-path +# real file is the strongest signal of user intent: they manually wrote it +# where the game reads from. Overrides anything already in SOT. +# Runs BEFORE seed_configs so seed (cp -n) doesn't paper over user intent. +# Requires CONFIG_SOT_DIR, ALT_CONFIG_DIR, CONFIG_FILE set. +auto_lift_user_config() { + local cfg="${CONFIG_FILE:-}" + [[ -z "$cfg" ]] && return 0 + [[ "${PLUTAINER_USE_RAW_CONFIGS:-}" == "true" ]] && return 0 + + local alt_path="$ALT_CONFIG_DIR/$cfg" + local sot_path="$CONFIG_SOT_DIR/$cfg" + + if [[ -f "$alt_path" && ! -L "$alt_path" ]]; then + echo "[INFO] Auto-lift: real file at $alt_path — moving to $sot_path (v2 SOT)." + mkdir -p "$CONFIG_SOT_DIR" + mv -f "$alt_path" "$sot_path" + fi +} + +# Verify $CONFIG_FILE exists at the SOT location. Returns 1 with a +# case-insensitive find hint if absent. Run AFTER seed_configs + link_configs +# so any gap-fill has had its chance. # Requires CONFIG_SOT_DIR, ALT_CONFIG_DIR, CONFIG_FILE set. ensure_config_present() { local cfg="$CONFIG_FILE" local sot_path="$CONFIG_SOT_DIR/$cfg" - local alt_path="$ALT_CONFIG_DIR/$cfg" if [[ -e "$sot_path" ]]; then return 0 fi - if [[ -f "$alt_path" && ! -L "$alt_path" ]]; then - echo "[INFO] Auto-lift: found $cfg at $ALT_CONFIG_DIR/ — moving to $CONFIG_SOT_DIR/ (v2 SOT)." - mkdir -p "$CONFIG_SOT_DIR" - mv "$alt_path" "$sot_path" - return 0 - fi - echo "[ERROR] PLUTAINER_CONFIG_FILE='$cfg' but no such file exists." >&2 echo " Looked at:" >&2 echo " $sot_path" >&2 - echo " $alt_path" >&2 + echo " $ALT_CONFIG_DIR/$cfg" >&2 local match match=$(find "$CONFIG_SOT_DIR" "$ALT_CONFIG_DIR" -maxdepth 1 -type f -iname "$cfg" 2>/dev/null | head -1) if [[ -n "$match" ]]; then diff --git a/scripts/iw4xentry.sh b/scripts/iw4xentry.sh index c14c9ff..360ca00 100644 --- a/scripts/iw4xentry.sh +++ b/scripts/iw4xentry.sh @@ -37,7 +37,10 @@ fi cd "$DEST_DIR" -# --- Step 3: Fan-out configs/ → engine + mod config dirs --- +# --- Step 3a: Auto-lift any user-placed real cfg from engine path --- +auto_lift_user_config + +# --- Step 3b: Fan-out configs/ → engine + mod config dirs --- # No seed_configs call: iw4x has no bundled community seed. link_configs "$ENGINE_CONFIG_DIR" "$MOD_CONFIG_DIR" diff --git a/scripts/plutoentry.sh b/scripts/plutoentry.sh index 4ee871f..cfd9ac8 100644 --- a/scripts/plutoentry.sh +++ b/scripts/plutoentry.sh @@ -61,7 +61,10 @@ fi cd "$PLUTAINER_PLUTONIUM_DIR" -# --- Step 3: Seed default configs from bundled community repos --- +# --- Step 3a: Auto-lift any user-placed real cfg from engine path --- +auto_lift_user_config + +# --- Step 3b: Seed default configs from bundled community repos --- if [[ "${PLUTAINER_SKIP_SEED:-}" != "true" ]]; then case "$BASE_GAME" in t4) seed_configs t4 "$PLUTAINER_GAMEFILES_DIR/main" "" ;; From 2b948e2819adcf04c7a83fe014cb00c72a22cb8a Mon Sep 17 00:00:00 2001 From: Amos Date: Wed, 13 May 2026 21:54:27 +0100 Subject: [PATCH 6/9] log-watcher: strip stray dirs at stable paths; reap orphan temp symlinks A sidecar that bind-mounts an individual log file (e.g. games_zm.log) before the file exists causes Docker to auto-create an empty root-owned directory at that host path. place_symlink's mv -Tf then fails every poll and litters logs/ with .XXX temp symlinks. Fix both reactively in place_symlink and on startup heal. Try rmdir first (empty dir, plutainer owns parent), fall back to rm -rf; if both fail (non- empty root-owned dir), log a clear error pointing at the sidecar config. --- scripts/log-watcher.sh | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/scripts/log-watcher.sh b/scripts/log-watcher.sh index aaefdc5..7c0d3cf 100644 --- a/scripts/log-watcher.sh +++ b/scripts/log-watcher.sh @@ -38,21 +38,55 @@ mkdir -p "$STABLE_DIR" # Atomically place a symlink at $link -> $rel_target. Works whether $link # currently does not exist, is a regular file (legacy stub), or is a symlink. +# If $link is a directory (typically created by a sidecar bind-mounting a +# non-existent log file, which makes Docker auto-create a dir on the host), +# strip it first — mv -T refuses to replace a directory. Without this strip, +# mv fails every poll and litters $STABLE_DIR with .XXX temp symlinks. place_symlink() { local link="$1" rel_target="$2" local tmp + if [[ -d "$link" && ! -L "$link" ]]; then + echo "[log-watcher] stray directory at $link (sidecar bind-mount artifact); removing" + if ! rmdir -- "$link" 2>/dev/null && ! rm -rf -- "$link" 2>/dev/null; then + echo "[log-watcher] ERROR: cannot remove $link — likely root-owned with root-owned contents." >&2 + echo "[log-watcher] Fix sidecar volume to mount the logs/ DIRECTORY, not individual log FILES." >&2 + return 1 + fi + fi tmp=$(mktemp -u -p "$STABLE_DIR" ".$(basename "$link").XXXXXX") ln -s "$rel_target" "$tmp" mv -Tf "$tmp" "$link" } -# Startup heal: any dangling symlinks from prior container runs become empty -# stub files so sidecar readers always see a file. If the old target is still -# valid, leave the symlink alone — IW4MAdmin can keep reading continuously -# until a fresh target is identified by the poll loop below. +# Startup heal: +# - Dangling symlinks from prior runs → empty stub files so sidecar readers +# always see a file. If the old target is still valid, leave the symlink +# alone — IW4MAdmin can keep reading continuously until a fresh target is +# identified by the poll loop below. +# - Stray directories (sidecar bind-mount of a non-existent log makes Docker +# auto-create a dir on the host) → remove. Otherwise place_symlink's +# mv -Tf fails forever and the namespace silts up with .XXX temp symlinks. +# - Stray orphan temp symlinks from prior failed place_symlink runs → remove. +if compgen -G "$STABLE_DIR/.*" > /dev/null 2>&1; then + for entry in "$STABLE_DIR"/.*; do + base=$(basename "$entry") + [[ "$base" == "." || "$base" == ".." ]] && continue + if [[ -L "$entry" && "$base" =~ ^\..+\.[A-Za-z0-9]{6}$ ]]; then + echo "[log-watcher] removing orphan temp symlink: $base" + rm -f -- "$entry" + fi + done +fi if compgen -G "$STABLE_DIR/*" > /dev/null; then for entry in "$STABLE_DIR"/*; do [[ -e "$entry" || -L "$entry" ]] || continue + if [[ -d "$entry" && ! -L "$entry" ]]; then + echo "[log-watcher] stray directory at $(basename "$entry") (sidecar bind-mount artifact); removing" + if ! rmdir -- "$entry" 2>/dev/null && ! rm -rf -- "$entry" 2>/dev/null; then + echo "[log-watcher] WARN: cannot remove $(basename "$entry") — likely root-owned with root-owned contents. Fix sidecar volume to mount the logs/ DIRECTORY, not individual log FILES." >&2 + fi + continue + fi if [[ -L "$entry" && ! -e "$entry" ]]; then echo "[log-watcher] healing dangling symlink: $(basename "$entry")" rm -f "$entry" From e845b49a624289fa5ceb3a0b33f3f31963145ff4 Mon Sep 17 00:00:00 2001 From: Amos Date: Wed, 13 May 2026 22:06:58 +0100 Subject: [PATCH 7/9] extract_rcon_password: accept bare 'rcon_password' (no set/seta prefix) T6/T7 community cfgs commonly write `rcon_password "value"` without the `set` keyword. Engine accepts both forms; parser only accepted the prefixed form, so healthcheck and rcon-cli silently failed on stock t6zm seeds. Make the `set[a]?` prefix optional and adjust the unquoted fallback to pick the right awk column whether or not the prefix is present. --- scripts/game-config.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/game-config.sh b/scripts/game-config.sh index f2e3302..6f0e2de 100644 --- a/scripts/game-config.sh +++ b/scripts/game-config.sh @@ -377,6 +377,7 @@ _rcon_missing_warning() { # set rcon_password "value" (double-quoted, default) # seta rcon_password 'value' (single-quoted) # set rcon_password value (unquoted; value is first token) +# rcon_password "value" (bare; T6/T7 community cfgs do this) # Strips line comments (//...) before searching. Picks the last uncommented # match, in case the cfg overrides itself. # Sets RCON_PASSWORD on success; returns 1 + warning on failure. @@ -389,7 +390,7 @@ extract_rcon_password() { local line line=$(sed -e 's|//.*$||' "${CONFIG_PATH}" \ - | grep -iE '^[[:space:]]*set[a]?[[:space:]]+rcon_password[[:space:]]+' \ + | grep -iE '^[[:space:]]*(set[a]?[[:space:]]+)?rcon_password[[:space:]]+' \ | tail -1) if [[ -z "$line" ]]; then @@ -404,8 +405,9 @@ extract_rcon_password() { elif [[ "$line" =~ $sq_pat ]]; then RCON_PASSWORD="${BASH_REMATCH[1]}" else - # Unquoted: token after 'set|seta rcon_password' - RCON_PASSWORD=$(echo "$line" | awk '{print $3}') + # Unquoted: value is the token immediately after 'rcon_password'. + # Strip optional 'set'/'seta' prefix so $2 is always the value. + RCON_PASSWORD=$(echo "$line" | awk '{ if ($1 ~ /^[Ss][Ee][Tt][Aa]?$/) print $3; else print $2 }') fi if [[ -z "$RCON_PASSWORD" ]]; then From c523692c34b3ed7feba29886bbdaf5eb9e216cbc Mon Sep 17 00:00:00 2001 From: nellydocs <71311423+nellydocs@users.noreply.github.com> Date: Wed, 13 May 2026 23:08:45 +0200 Subject: [PATCH 8/9] Arm64 v2 (#3) * Add ARM64 image variant with native Wine 11 via Hangover New Dockerfile.arm64 targets aarch64 (Pi 5+, Ampere, Apple silicon under Linux). Multi-stage build: 1. rust-builder -> native aarch64 plutonium-updater (upstream ships no aarch64-linux release). 2. cpp-builder -> native aarch64 iw4x-launcher via build2 0.18.1 compiled from source. 3. runtime -> Debian 13 trixie + Hangover 11.4 (.deb bundle: Wine 11 + FEX 2603 + box64 as Wine CPU plug-ins). x86_64 Windows PE binaries (Plutonium/IW4x/T7x server exes) run under Wine 11 thunked WoW64 with libarm64ecfex.dll as the CPU backend. No user-space x86 emulator is required because the helper CLIs are native aarch64. scripts/ unchanged - the entrypoints call /home/plutainer/.plutainer/ {plutonium-updater,iw4x-launcher} by fixed path, and we drop a native aarch64 ELF at exactly that path. The amd64 Dockerfile and its workflow are untouched. CI: .github/workflows/docker-publish-arm64.yml builds via QEMU on ubuntu-latest and pushes ghcr.io/:arm64. Comment in the file notes the ubuntu-24.04-arm runner swap for a 3-5x speedup. Co-Authored-By: Claude Opus 4.7 (1M context) * Add curl to arm64 cpp-builder stage so build2 installer runs The build2-install-*.sh script unconditionally `check_cmd curl`s before fetching the toolchain source tarball; the stage shipped only wget, so the installer exited 1 on first ARM64 build. Add curl to the apt list and use it for the script download too for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) * Switch arm64 CI to native ubuntu-24.04-arm runner Plutainer is a public repo, so ARM-hosted runners are free. The Multi-Stage Dockerfile compiles build2 from source and runs full cargo/build2 builds; QEMU-emulated on ubuntu-latest that runs 60-90 min and exposes flaky edge cases. Native aarch64 should land at 20-30 min and removes QEMU as a failure surface. setup-qemu-action removed (not needed on native arm runner). Co-Authored-By: Claude Opus 4.7 (1M context) * Adapt arm64 image to v2-layout: add migrate-v1-to-v2.sh, retag Two narrow tweaks on top of the v2 scripts: * Dockerfile.arm64: include migrate-v1-to-v2.sh in the chmod list so the v2 entrypoint can find it executable (was missing because the original arm64 commit predated v2). * CI workflow: trigger on `arm64-v2` and `v2-layout` too, and emit a clean `:arm64-v2` tag for those branches instead of the v1-style `arm64-{{branch}}` pattern which would produce `arm64-arm64-v2`. scripts/ themselves come unchanged from upstream v2-layout. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/docker-publish-arm64.yml | 62 +++++++++ Dockerfile.arm64 | 154 +++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 .github/workflows/docker-publish-arm64.yml create mode 100644 Dockerfile.arm64 diff --git a/.github/workflows/docker-publish-arm64.yml b/.github/workflows/docker-publish-arm64.yml new file mode 100644 index 0000000..7c44907 --- /dev/null +++ b/.github/workflows/docker-publish-arm64.yml @@ -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/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 diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 new file mode 100644 index 0000000..0df3240 --- /dev/null +++ b/Dockerfile.arm64 @@ -0,0 +1,154 @@ +# ARM64 variant (Raspberry Pi 5, Ampere, Apple silicon under Linux). +# +# Architecture translation strategy: +# * x86_64 Windows PE (Plutonium/IW4x/T7x .exe) -> Wine 11 thunked WoW64 + +# libarm64ecfex.dll (FEX as CPU plug-in, shipped by Hangover 11.4). +# * Linux helper CLIs (plutonium-updater, iw4x-launcher) -> built natively +# for aarch64 from source. Upstream ships no aarch64-linux release, so the +# alternative would be running their x86_64 binaries under box64; building +# native instead drops the user-space x86 emulator from the runtime image. +# +# Base: Debian 13 trixie. Hangover publishes official .deb bundles for this +# exact distro/arch, so Wine 11 is consumed pre-built. + +# --------------------------------------------------------------------------- +# Stage 1: build plutonium-updater (Rust) natively for aarch64-linux. +# --------------------------------------------------------------------------- +FROM --platform=linux/arm64 rust:1-trixie AS rust-builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates pkg-config libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +RUN git clone --depth 1 https://github.com/mxve/plutonium-updater.rs.git +WORKDIR /build/plutonium-updater.rs +RUN cargo build --release + +# --------------------------------------------------------------------------- +# Stage 2: build iw4x-launcher (C++ via build2) natively for aarch64-linux. +# --------------------------------------------------------------------------- +FROM --platform=linux/arm64 debian:trixie-slim AS cpp-builder + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl wget git \ + g++ gcc make \ + tar xz-utils \ + && rm -rf /var/lib/apt/lists/* + +# build2 ships no aarch64-linux binaries; the install script compiles the +# toolchain from source. The installer uses curl internally to fetch the +# source tarball (see check_cmd curl in build2-install-*.sh). --trust yes +# accepts the cppget.org repo cert without manual fingerprint entry +# (acceptable inside a one-shot build container). +RUN curl -fL -o build2-install.sh https://download.build2.org/0.18.1/build2-install-0.18.1.sh \ + && sh build2-install.sh --trust yes --no-check --jobs $(nproc) --yes /usr/local \ + && rm build2-install.sh + +WORKDIR /build +RUN git clone --depth 1 https://github.com/iw4x/launcher.git iw4x-launcher +WORKDIR /build/iw4x-launcher + +# bindist-gcc-static.sh prompts for confirmation; -y answers yes. The script +# emits a flattened .tar.xz under dist-output/ containing only the binary. +RUN sh bindist-gcc-static.sh -y -j $(nproc) "launcher@https://github.com/iw4x/launcher.git#main" + +RUN mkdir -p /out \ + && tar -xJf dist-output/*.tar.xz -C /out \ + && find /out -type f -name 'launcher' -exec mv {} /out/iw4x-launcher \; + +# --------------------------------------------------------------------------- +# Stage 3: runtime image. +# --------------------------------------------------------------------------- +FROM --platform=linux/arm64 debian:trixie-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + wget \ + tar \ + xz-utils \ + unzip \ + jq \ + python3 \ + procps \ + findutils \ + xvfb \ + xauth \ + && rm -rf /var/lib/apt/lists/* + +# Hangover 11.4 = Wine 11 + FEX 2603 + box64 as Wine CPU plug-ins (the +# in-Wine box64 is unrelated to a Linux-user-space box64; we do not need +# the latter because the helper CLIs are native aarch64). +RUN HANGOVER_TAG="hangover-11.4" \ + && HANGOVER_TAR="hangover_11.4_debian13_trixie_arm64.tar" \ + && wget -q "https://github.com/AndreRH/hangover/releases/download/${HANGOVER_TAG}/${HANGOVER_TAR}" \ + && mkdir hangover-pkg \ + && tar -xf "${HANGOVER_TAR}" -C hangover-pkg \ + && apt-get update \ + && apt-get install -y --no-install-recommends ./hangover-pkg/*.deb \ + && rm -rf hangover-pkg "${HANGOVER_TAR}" /var/lib/apt/lists/* + +RUN useradd -m plutainer +RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix + +ENV WINEDLLOVERRIDES="mscoree,mshtml=" \ + DISPLAY=:99 + +USER plutainer +WORKDIR /home/plutainer/.plutainer + +RUN Xvfb :99 -screen 0 320x240x24 & \ + sleep 1 && \ + wineboot -u && \ + wineserver -w && \ + pkill -f Xvfb || true && \ + rm -f /tmp/.X99-lock + +COPY --from=rust-builder --chown=plutainer:plutainer \ + /build/plutonium-updater.rs/target/release/plutonium-updater \ + /home/plutainer/.plutainer/plutonium-updater + +COPY --from=cpp-builder --chown=plutainer:plutainer \ + /out/iw4x-launcher \ + /home/plutainer/.plutainer/iw4x-launcher + +# Bundle community config seeds for first-run scaffolding. Entrypoint copies +# these into the bind mount on start with cp -n (never overwrites user files). +# Source repos: xerxes-at/T{4,5,6}ServerConfig*, xerxes-at/IW5ServerConfig, +# Dss0/t7-server-config. Disable per-stack via PLUTO_SKIP_SEED/ALTER_SKIP_SEED. +RUN set -eux; \ + mkdir -p seed-configs/{t4,t5,t6,iw5,t7x}; \ + cd /tmp; \ + wget -q -O t4.zip https://github.com/xerxes-at/T4ServerConfigs/archive/refs/heads/main.zip && unzip -q t4.zip; \ + wget -q -O t5.zip https://github.com/xerxes-at/T5ServerConfig/archive/refs/heads/master.zip && unzip -q t5.zip; \ + wget -q -O t6.zip https://github.com/xerxes-at/T6ServerConfigs/archive/refs/heads/master.zip && unzip -q t6.zip; \ + wget -q -O iw5.zip https://github.com/xerxes-at/IW5ServerConfig/archive/refs/heads/master.zip && unzip -q iw5.zip; \ + wget -q -O t7x.zip https://github.com/Dss0/t7-server-config/archive/refs/heads/main.zip && unzip -q t7x.zip; \ + cp -r T4ServerConfigs-main/main/. /home/plutainer/.plutainer/seed-configs/t4/; \ + cp -r T5ServerConfig-master/localappdata/Plutonium/storage/t5/. /home/plutainer/.plutainer/seed-configs/t5/; \ + cp -r T6ServerConfigs-master/localappdata/Plutonium/storage/t6/. /home/plutainer/.plutainer/seed-configs/t6/; \ + cp -r IW5ServerConfig-master/admin/. /home/plutainer/.plutainer/seed-configs/iw5/; \ + cp -r t7-server-config-main/zone /home/plutainer/.plutainer/seed-configs/t7x/; \ + cp -r t7-server-config-main/t7x /home/plutainer/.plutainer/seed-configs/t7x/; \ + rm -rf /tmp/*.zip /tmp/T4ServerConfigs-main /tmp/T5ServerConfig-master /tmp/T6ServerConfigs-master /tmp/IW5ServerConfig-master /tmp/t7-server-config-main; \ + find /home/plutainer/.plutainer/seed-configs -type d -iname '*REFERENCE*' -exec rm -rf {} +; \ + 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 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 \ + CMD ./healthcheck.sh + +ENTRYPOINT ["./entrypoint.sh"] From e7a4caa8e4b85a830d865082b400efd495d5ee32 Mon Sep 17 00:00:00 2001 From: Amos Date: Mon, 25 May 2026 11:55:16 +0100 Subject: [PATCH 9/9] v2: combined v1-deployment refusal + MIGRATION.md + workflow for :latest promotion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-promotion to :latest, the v2 image needs to refuse v1 deployments cleanly. Previously the volume check printed its own refusal block but the env-var side was silent — a user pulling :latest (post-promotion) onto their v1 compose would have hit "PLUTAINER_CONFIG_FILE not set" with no hint that the rename was the cause. Changes: - detect_legacy_env_vars: scans PLUTO_*/IW4X_*/ALTER_* names, populates LEGACY_ENVS_FOUND. Returns non-zero if any are set. - check_volume_version: still validates marker / inits fresh / detects v1, but no longer prints the v1 block inline. Sets V1_VOLUME_DETECTED=true on v1 detection and returns. Marker-mismatch errors still print. - print_v1_migration_block: combined refusal block, adapts to what was detected (env, volume, or both). Shows both paths side by side — pin :v1-final or migrate via the bundled tool + env rename. - entrypoint.sh: runs both detectors before deciding to hold, so a single block surfaces every reason for refusal. - MIGRATION.md: full env mapping table, step-by-step migration, rollback. - README.md: top banner rewritten for the post-promotion tag layout. :latest = v2, :v1-final = frozen v1. - docker-publish.yml + docker-publish-arm64.yml: :v2 / :arm64-v2 enable predicates now also fire on main, so the post-merge build publishes both the canonical (:latest / :arm64) and the alias tags. --- .github/workflows/docker-publish-arm64.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- MIGRATION.md | 155 +++++++++++++++++++++ README.md | 46 +++--- scripts/entrypoint.sh | 32 ++++- scripts/game-config.sh | 151 +++++++++++++++----- 6 files changed, 322 insertions(+), 66 deletions(-) create mode 100644 MIGRATION.md diff --git a/.github/workflows/docker-publish-arm64.yml b/.github/workflows/docker-publish-arm64.yml index 7c44907..4e376a1 100644 --- a/.github/workflows/docker-publish-arm64.yml +++ b/.github/workflows/docker-publish-arm64.yml @@ -45,7 +45,7 @@ jobs: images: ghcr.io/${{ github.repository }} tags: | type=raw,value=arm64,enable={{is_default_branch}} - type=raw,value=arm64-v2,enable=${{ github.ref == 'refs/heads/v2-layout' || github.ref == 'refs/heads/arm64-v2' }} + 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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c769d46..5947850 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -39,7 +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/v2-layout' }} + 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 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..a897f2e --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,155 @@ +# Migration: Plutainer v1 → v2 + +Plutainer v2 is now published as `ghcr.io/ayymoss/plutainer:latest` (and `:v2` as a permanent alias). The previous v1 image is frozen at `ghcr.io/ayymoss/plutainer:v1-final` and `:v1`. + +This page covers everything you need to migrate an existing v1 deployment. + +If you do not want to migrate right now, [pin v1-final](#path-a--stay-on-v1) instead and come back when you are ready. v1-final will continue to start, but it shows a deprecation banner every time and receives no further updates. + +--- + +## What changed? + +1. **Unified environment variables.** The old `PLUTO_*`, `IW4X_*`, and `ALTER_*` prefixes are gone. Everything is now `PLUTAINER_*` and game family is derived from `PLUTAINER_GAME`. +2. **New volume layout** inside `/home/plutainer/app/`: + ``` + v1 layout v2 layout + ───────── ───────── + app/gamefiles/ app/configs/ ← your *.cfg files (flat) + app/plutonium/ app/runtime/gamefiles/ + app/logs/ app/runtime/plutonium/ + app/logs/ ← stable symlinks to active logs + .plutainer-version ← layout marker (contains "2") + ``` + You now edit configs in one flat `app/configs/` directory. The entrypoint places relative symlinks from there into wherever the game engine expects them, so a single edit reaches both the engine path and any mod path. +3. **`app/logs/`** is now maintained by `log-watcher.sh` — every `*.log` under `app/` is surfaced as a stable relative symlink in this directory, so sidecars (IW4MAdmin) can read from one predictable path regardless of which mod dir the game is writing to. + +--- + +## Path A — Stay on v1 + +```yaml +# docker-compose.yml +services: + myserver: + image: ghcr.io/ayymoss/plutainer:v1-final # was: :latest + # ... everything else stays as-is +``` + +Then: + +```bash +docker compose pull +docker compose up -d +``` + +A deprecation banner appears in `docker logs` on every start. The server otherwise runs unchanged. + +--- + +## Path B — Migrate to v2 + +### Step 1 — Stop the container + +```bash +docker compose down +``` + +### Step 2 — Migrate the volume layout + +Run the bundled migration tool against your existing volume. The tool moves files in place, replacing engine-path config files with relative symlinks back into the new `app/configs/` tree. **No data is deleted.** + +```bash +docker run --rm \ + -v :/home/plutainer/app \ + --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ + ghcr.io/ayymoss/plutainer:v2 +``` + +`` is whatever host path is bound to `/home/plutainer/app` in your compose. For a directory-style mount like `./t6zm-1:/home/plutainer/app`, that is `./t6zm-1`. + +**Preview first** by appending `--dry-run` — the tool prints every move it would make without touching the filesystem. + +After the tool runs, your volume contains: +- `app/configs/` (real files, moved from old engine paths) +- Symlinks from the old engine paths back into `app/configs/` so the game still finds them +- `app/.plutainer-version` containing `2` +- `app/runtime/gamefiles/`, `app/runtime/plutonium/` (your old `app/gamefiles/`, `app/plutonium/` moved here) + +### Step 3 — Rename your environment variables + +Anything not listed in this table keeps its old name (notably `PLUTO_SERVER_KEY`, `PLUTO_MAX_CLIENTS`, `IW4X_NET_LOG_IP` — these are unchanged). + +| v1 name | v2 name | +| -------------------- | ---------------------------------- | +| `PLUTO_GAME` | `PLUTAINER_GAME` | +| `PLUTO_CONFIG_FILE` | `PLUTAINER_CONFIG_FILE` | +| `PLUTO_PORT` | `PLUTAINER_PORT` | +| `PLUTO_HEALTHCHECK` | `PLUTAINER_HEALTHCHECK` | +| `PLUTO_MOD` | `PLUTAINER_MOD` | +| `PLUTO_SKIP_SEED` | `PLUTAINER_SKIP_SEED` | +| `PLUTO_AUTO_UPDATE` | `PLUTAINER_AUTO_UPDATE` | +| `PLUTO_SERVER_NAME` | `PLUTAINER_SERVER_NAME` | +| `PLUTO_EXTRA_ARGS` | `PLUTAINER_EXTRA_ARGS` | +| `IW4X_GAME` | `PLUTAINER_GAME=iw4x` | +| `IW4X_CONFIG_FILE` | `PLUTAINER_CONFIG_FILE` | +| `IW4X_PORT` | `PLUTAINER_PORT` | +| `IW4X_HEALTHCHECK` | `PLUTAINER_HEALTHCHECK` | +| `IW4X_MOD` | `PLUTAINER_MOD` | +| `IW4X_AUTO_UPDATE` | `PLUTAINER_AUTO_UPDATE` | +| `IW4X_SERVER_NAME` | `PLUTAINER_SERVER_NAME` | +| `IW4X_EXTRA_ARGS` | `PLUTAINER_EXTRA_ARGS` | +| `ALTER_GAME` | `PLUTAINER_GAME` (e.g. `t7x`) | +| `ALTER_CONFIG_FILE` | `PLUTAINER_CONFIG_FILE` | +| `ALTER_PORT` | `PLUTAINER_PORT` | +| `ALTER_HEALTHCHECK` | `PLUTAINER_HEALTHCHECK` | +| `ALTER_MOD` | `PLUTAINER_MOD` | +| `ALTER_SKIP_SEED` | `PLUTAINER_SKIP_SEED` | +| `ALTER_AUTO_UPDATE` | `PLUTAINER_AUTO_UPDATE` | +| `ALTER_SERVER_NAME` | `PLUTAINER_SERVER_NAME` | +| `ALTER_EXTRA_ARGS` | `PLUTAINER_EXTRA_ARGS` | + +Update the `image:` line in your compose: + +```yaml +image: ghcr.io/ayymoss/plutainer:latest # or :v2 (alias) +``` + +### Step 4 — Start + +```bash +docker compose up -d +docker compose logs -f +``` + +You should see a fresh boot ending in `Starting Plutonium Server: ...` (or the equivalent for IW4x/Alterware) followed by the game process. Healthcheck flips to `healthy` once RCON responds. + +--- + +## Troubleshooting + +**Container holds with a v1 deployment refusal block.** +You started v2 against either a v1 volume, v1 env vars, or both. The block lists exactly what was detected. Fix what it lists, then `docker compose up -d`. The container does not restart-loop — it stays `Up` with `sleep infinity` so you can read the message in `docker logs`. + +**Container holds with "Config file not found".** +Your `PLUTAINER_CONFIG_FILE` does not match a file in `app/configs/`. The block prints a case-insensitive hint (`Did you mean: ...?`) — filenames on Linux are case-sensitive. + +**RCON / healthcheck warns "Could not parse rcon_password".** +The parser accepts `set rcon_password "..."`, `seta rcon_password '...'`, unquoted values, and the bare `rcon_password "..."` form. It strips `//` line comments. Do **not** set `rcon_password` via `PLUTAINER_EXTRA_ARGS` — it cannot be read back. + +**I want the old behaviour where I edit configs directly in the engine path.** +Set `PLUTAINER_USE_RAW_CONFIGS=true`. The fan-out symlinks are skipped and the engine path becomes the source of truth. + +--- + +## Rolling back + +If something goes wrong, you can return to v1 immediately: + +```yaml +image: ghcr.io/ayymoss/plutainer:v1-final +``` + +`docker compose up -d`. The v2 migration tool only moved files inside the bind-mounted volume — it did not delete anything. v1-final will read the v1 layout it remembers, ignore the new `app/configs/` and `app/runtime/` dirs, and start as before. + +If you need to fully restore the pre-migration state (so the dirs from v2 are removed), back up first and then `mv` the contents of `app/runtime/gamefiles/` back to `app/gamefiles/` and `app/runtime/plutonium/` back to `app/plutonium/`. Symlinks at engine paths can be replaced with the real files from `app/configs/`. Most users will not need this. diff --git a/README.md b/README.md index a56acc5..2bba048 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ This repository contains the necessary files to build and run dedicated game servers for Plutonium, IW4x, and Alterware using Docker. The image is designed to be flexible and configurable through environment variables. -The container is available on GitHub Container Registry: `ghcr.io/ayymoss/plutainer:v2` +The container is available on GitHub Container Registry: `ghcr.io/ayymoss/plutainer:latest` -> **Tag note (v2):** The `v2` tag is a **breaking change** from `latest`. It introduces a new volume layout, unified `PLUTAINER_*` environment variables (the legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` prefixes are **not accepted**), and stricter startup validation. It is built from the `v2-layout` branch and is intentionally separate from `latest` — the two tags do not share builds. The `latest` tag continues to point at the older v1 image (legacy env vars, flat `app/gamefiles/` + `app/plutonium/` layout) and is deprecated; no further updates. If you are migrating an existing deployment, see [Upgrading from v1](#upgrading-from-v1). Opt in by changing your `image:` line to `ghcr.io/ayymoss/plutainer:v2`. +> **Tag layout:** +> - `:latest` (and the `:v2` alias) — current Plutainer v2. New volume layout, unified `PLUTAINER_*` environment variables. Built from `main`. +> - `:v1-final` (and the `:v1` alias) — frozen snapshot of the old v1 image. Legacy `PLUTO_*`/`IW4X_*`/`ALTER_*` env vars, flat `app/gamefiles/` + `app/plutonium/` layout. Shows a deprecation banner on every start. No further updates, fixes, or security patches. +> +> **Upgrading from v1?** See [MIGRATION.md](MIGRATION.md) — covers the env var rename, the volume migration command (one `docker run`), and how to pin `:v1-final` if you want to defer the migration. ## Overview @@ -131,34 +135,22 @@ You can toggle this between restarts. Plutainer doesn't migrate files when you f ### Upgrading from v1 -If you have an existing deployment running the older `:latest` tag, your `app/` volume is in the **v1 layout** (no `configs/` or `runtime/` dirs, `gamefiles/` and `plutonium/` at the top level). The v2 container will refuse to start against a v1 volume — running it produces a clear error pointing to this section. +If you have an existing deployment that was running an older `:latest` (now `:v1-final`), pulling the new `:latest` (v2) refuses to start: v2 detects either v1 environment variables (`PLUTO_*`/`IW4X_*`/`ALTER_*`) or the v1 volume layout (`app/gamefiles/`, `app/plutonium/` at the top level with no `.plutainer-version` marker) and prints a combined refusal block in `docker logs` listing exactly what was detected, plus the two paths forward. -Run the bundled migration tool once per volume: +**Full step-by-step guide:** [MIGRATION.md](MIGRATION.md) — env var mapping table, the one-command volume migration, dry-run option, and how to pin `:v1-final` if you would rather defer. -```sh -docker run --rm \ - -v :/home/plutainer/app \ - --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ - ghcr.io/ayymoss/plutainer:v2 -``` - -Replace `` with the path bound to `/home/plutainer/app` in your compose file (e.g. `./t6zm-1`). The tool: - -1. Creates `runtime/` and `configs/`. -2. Moves `app/gamefiles/` → `app/runtime/gamefiles/`. -3. Moves `app/plutonium/` → `app/runtime/plutonium/`. -4. Lifts every top-level `*.cfg` from the engine config dirs into `app/configs/`, leaving a relative symlink in its place. -5. Clears stale entries in `app/logs/` (the log-watcher repopulates on next start). -6. Writes `app/.plutainer-version=2`. +Quick summary of the migration path: -Add `--dry-run` after the entrypoint to preview without modifying anything: - -```sh -docker run --rm \ - -v :/home/plutainer/app \ - --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ - ghcr.io/ayymoss/plutainer:v2 --dry-run -``` +1. `docker compose down` +2. Run the migration tool once per volume (append `--dry-run` to preview): + ```sh + docker run --rm \ + -v :/home/plutainer/app \ + --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ + ghcr.io/ayymoss/plutainer:v2 + ``` +3. Rename `PLUTO_*`/`IW4X_*`/`ALTER_*` env vars to `PLUTAINER_*` in your compose (full table in MIGRATION.md). +4. `docker compose up -d` If you also have IW4MAdmin sidecar mounts pointing at log paths like `./t6zm-1/plutonium/storage/...`, update them to `./t6zm-1/runtime/plutonium/storage/...` — or better, switch to the stable [log symlink directory](#log-symlinks). diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 2ec92ee..cda3a57 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -23,16 +23,38 @@ echo echo "Brought to you by Ayymoss" echo +# --- v1 deployment detection (combined env + volume) --- +# Order matters: run BOTH detectors before deciding to hold, so the user sees +# every reason for refusal in a single block instead of fixing one thing, +# restarting, and hitting the next refusal. +HAS_LEGACY_ENV=false +HAS_V1_VOLUME=false + +detect_legacy_env_vars || HAS_LEGACY_ENV=true + +# check_volume_version handles fresh-init and v2 marker validation as side +# effects. It sets V1_VOLUME_DETECTED=true when v1 dirs are present without +# a marker (without printing); any other return-1 path means a real volume +# state error (e.g. unknown future-version marker), which already printed. +VOLUME_CHECK_RC=0 +check_volume_version || VOLUME_CHECK_RC=$? +if [[ "${V1_VOLUME_DETECTED:-false}" == "true" ]]; then + HAS_V1_VOLUME=true +elif [[ $VOLUME_CHECK_RC -ne 0 ]]; then + # Non-v1 volume error (already printed its own message). + hold_indefinitely "Volume layout check failed. See above." +fi + +if [[ "$HAS_LEGACY_ENV" == "true" || "$HAS_V1_VOLUME" == "true" ]]; then + print_v1_migration_block "$HAS_LEGACY_ENV" "$HAS_V1_VOLUME" + hold_indefinitely "v1 deployment detected. Pick one of the paths above, then restart." +fi + # --- Detect game type from PLUTAINER_GAME --- if ! detect_game_type; then hold_indefinitely "Set PLUTAINER_GAME to one of: t4mp, t4sp, t5mp, t5sp, t6mp, t6zm, iw5mp, iw4x, t7x" fi -# --- Validate (or initialise) volume layout --- -if ! check_volume_version; then - hold_indefinitely "Volume layout check failed. See the migration instructions above." -fi - # --- Dispatch to game-specific entrypoint --- case "$GAME_TYPE" in plutonium) diff --git a/scripts/game-config.sh b/scripts/game-config.sh index 6f0e2de..68eb919 100644 --- a/scripts/game-config.sh +++ b/scripts/game-config.sh @@ -306,10 +306,41 @@ ensure_config_present() { return 1 } -# Check that the mounted app/ volume is v2-shaped. On a fresh volume, -# initialise it. On a v1 volume, refuse to start with explicit migration -# instructions. On a v2 volume, just ensure the expected dirs exist. +# Scan environment for v1-era legacy env var names. Populates +# LEGACY_ENVS_FOUND with each name that is set+non-empty. Returns 0 if none +# found (clean v2 env), 1 if any are present. Caller is responsible for +# printing the unified refusal block. +detect_legacy_env_vars() { + LEGACY_ENVS_FOUND=() + local v + local legacy_names=( + PLUTO_GAME PLUTO_CONFIG_FILE PLUTO_PORT PLUTO_HEALTHCHECK + PLUTO_SKIP_SEED PLUTO_AUTO_UPDATE PLUTO_MOD PLUTO_SERVER_NAME PLUTO_EXTRA_ARGS + IW4X_GAME IW4X_CONFIG_FILE IW4X_PORT IW4X_HEALTHCHECK + IW4X_AUTO_UPDATE IW4X_MOD IW4X_SERVER_NAME IW4X_EXTRA_ARGS + ALTER_GAME ALTER_CONFIG_FILE ALTER_PORT ALTER_HEALTHCHECK + ALTER_SKIP_SEED ALTER_AUTO_UPDATE ALTER_MOD ALTER_SERVER_NAME ALTER_EXTRA_ARGS + ) + for v in "${legacy_names[@]}"; do + if [[ -n "${!v:-}" ]]; then + LEGACY_ENVS_FOUND+=("$v") + fi + done + [[ ${#LEGACY_ENVS_FOUND[@]} -eq 0 ]] +} + +# Check the mounted app/ volume's layout state. +# Outcomes (silent on v1 detection — the unified refusal block is printed by +# entrypoint.sh via print_v1_migration_block): +# - Marker present + matches PLUTAINER_VOLUME_VERSION: ensure expected dirs +# exist, return 0. +# - Marker present but version mismatch (e.g. future v3 marker under v2 +# image): print specific error, return 1. +# - Marker absent + v1 layout dirs present: set V1_VOLUME_DETECTED=true, +# return 1. No print. +# - Marker absent + no v1 dirs: fresh volume — initialise as v2, return 0. check_volume_version() { + V1_VOLUME_DETECTED=false local marker="$PLUTAINER_APP_DIR/.plutainer-version" if [[ -f "$marker" ]]; then @@ -326,35 +357,7 @@ check_volume_version() { # Marker missing. Distinguish v1 volume vs fresh volume. if [[ -d "$PLUTAINER_APP_DIR/plutonium" || -d "$PLUTAINER_APP_DIR/gamefiles" ]]; then - cat >&2 <<'EOF' -======================================================================== -[ERROR] v1 volume layout detected. This image (Plutainer v2) uses a new -layout that places user-editable configs in a single, predictable -directory. - - v1 layout: v2 layout: - app/gamefiles/ app/configs/ (your cfgs) - app/plutonium/ app/runtime/gamefiles/ - app/logs/ app/runtime/plutonium/ - app/logs/ - -To migrate this volume, stop the container and run the bundled migration -tool against your bind mount: - - docker run --rm \ - -v :/home/plutainer/app \ - --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ - ghcr.io/ayymoss/plutainer:v2 - -(For a docker-compose deployment, is the path bound to -/home/plutainer/app — e.g. ./t6zm-1) - -The tool moves files in place; configs become symlinks back into the new -app/configs/ tree. No data is deleted. Re-run with --dry-run to preview. - -Refusing to start to avoid corrupting your data. -======================================================================== -EOF + V1_VOLUME_DETECTED=true return 1 fi @@ -364,6 +367,90 @@ EOF echo "[INFO] Initialised fresh v2 volume at $PLUTAINER_APP_DIR" } +# Combined v1-deployment refusal block. Adapts to what was detected. +# Args: $1=has_legacy_env (true/false), $2=has_v1_volume (true/false). +# Reads LEGACY_ENVS_FOUND[] populated by detect_legacy_env_vars. +print_v1_migration_block() { + local has_legacy_env="${1:-false}" has_v1_volume="${2:-false}" + + cat >&2 <<'HEADER' +======================================================================== +[ERROR] Plutainer v2 cannot start against a v1 deployment. + +Detected on this container: +HEADER + + if [[ "$has_legacy_env" == "true" ]]; then + echo " - Legacy env vars set: ${LEGACY_ENVS_FOUND[*]}" >&2 + fi + if [[ "$has_v1_volume" == "true" ]]; then + echo " - v1 volume layout (app/plutonium/ or app/gamefiles/ present, no .plutainer-version marker)" >&2 + fi + + cat >&2 <<'PATHS' + +You have two paths. Pick one. + +──────────────────────────────────────────────────────────────────────── +PATH A — Stay on v1 (frozen, no further updates) + + In your compose file, pin: + image: ghcr.io/ayymoss/plutainer:v1-final + + Then: docker compose up -d + Server starts as before. A deprecation banner will appear on every + start until you migrate. + +──────────────────────────────────────────────────────────────────────── +PATH B — Migrate to v2 (recommended) + + 1. docker compose down + + 2. Migrate the volume layout (no data is deleted; --dry-run previews): + docker run --rm \ + -v :/home/plutainer/app \ + --entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \ + ghcr.io/ayymoss/plutainer:v2 + + is the host path bound to /home/plutainer/app + in your compose (e.g. ./t6zm-1). + + 3. Rename env vars in your compose (mapping table — anything not listed + here keeps its old name, e.g. PLUTO_SERVER_KEY is unchanged): + PLUTO_GAME → PLUTAINER_GAME + PLUTO_CONFIG_FILE → PLUTAINER_CONFIG_FILE + PLUTO_PORT → PLUTAINER_PORT + PLUTO_HEALTHCHECK → PLUTAINER_HEALTHCHECK + PLUTO_MOD → PLUTAINER_MOD + PLUTO_SKIP_SEED → PLUTAINER_SKIP_SEED + PLUTO_AUTO_UPDATE → PLUTAINER_AUTO_UPDATE + PLUTO_SERVER_NAME → PLUTAINER_SERVER_NAME + PLUTO_EXTRA_ARGS → PLUTAINER_EXTRA_ARGS + IW4X_GAME → PLUTAINER_GAME=iw4x + IW4X_CONFIG_FILE → PLUTAINER_CONFIG_FILE + IW4X_PORT → PLUTAINER_PORT + IW4X_HEALTHCHECK → PLUTAINER_HEALTHCHECK + IW4X_MOD → PLUTAINER_MOD + IW4X_AUTO_UPDATE → PLUTAINER_AUTO_UPDATE + IW4X_SERVER_NAME → PLUTAINER_SERVER_NAME + IW4X_EXTRA_ARGS → PLUTAINER_EXTRA_ARGS + ALTER_GAME → PLUTAINER_GAME (e.g. t7x) + ALTER_CONFIG_FILE → PLUTAINER_CONFIG_FILE + ALTER_PORT → PLUTAINER_PORT + ALTER_HEALTHCHECK → PLUTAINER_HEALTHCHECK + ALTER_MOD → PLUTAINER_MOD + ALTER_SKIP_SEED → PLUTAINER_SKIP_SEED + ALTER_AUTO_UPDATE → PLUTAINER_AUTO_UPDATE + ALTER_SERVER_NAME → PLUTAINER_SERVER_NAME + ALTER_EXTRA_ARGS → PLUTAINER_EXTRA_ARGS + + Full guide: https://github.com/Ayymoss/Plutainer/blob/main/MIGRATION.md + + 4. docker compose up -d +======================================================================== +PATHS +} + _rcon_missing_warning() { echo "[WARN] Could not parse rcon_password from ${CONFIG_PATH}." >&2 echo " Healthcheck and rcon-cli will not work until this is set." >&2