og-cod-plutainer/scripts/lib/core.sh
Amos f9a7953652
Some checks failed
Build and Push Docker Image / build (amd64, Dockerfile, false, linux/amd64, ubuntu-latest) (push) Failing after 2s
Build and Push Docker Image / build (arm64, Dockerfile.arm64, true, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Image / merge (push) Has been cancelled
SteamCMD family: generalise 7DTD support, add CS2/L4D2/HL2:DM, unify the CoD side (#9)
* feat: add 7 Days to Die server support

* refactor(scripts): two table-driven families behind one entry script each

Plutainer had five "families" that were really four Call of Duty engines
plus a platform, and a separate entry script per engine. Adding 7 Days to
Die made the mismatch obvious: a SteamCMD game shares nothing with a
Quake-derived one, while a Plutonium T6 and a CoD4x server differ far
less from each other than either does from a Steam install.

There are now two families, and they are platforms:

  cod    servers Plutainer installs and runs from game files you supply
  steam  servers SteamCMD installs

Engine (plutonium/iw4x/alterware/cod4x, unity/srcds) drops to a field in
that family's game table, where it belongs. Each family gets one entry
script driven by that table, so the four CoD entry scripts collapse into
games/codentry.sh at 92 lines, and adding a game is a table row rather
than a new script. entrypoint.sh has two branches and gains none per game.

Per-engine behaviour is expressed as hooks resolved most-specific-first:

  cod_<hook>_<game> -> cod_<hook>_<base game> -> cod_<hook>_<engine>

so a game inherits its engine and overrides only what genuinely differs.
The same mechanism serves both families, which removed fifteen one-line
pass-through functions on the Steam side.

Hook names are strings, so plutainer_require_hooks verifies the mandatory
ones resolve at startup and prints what it tried. That is not theoretical:
during this refactor a comma-separated suffix list was passed where an
array was wanted, and every server died with no output whatsoever until
plutainer_hook was made to say what it could not find.

The library splits along the same line — core/fs/cod/steam — so a CoD
change never requires reading the Steam helpers. Audited: zero
cross-family calls in either direction. Anything both needed (the symlink
helpers) moved to fs.sh. Renamed from *-config.sh, which was inaccurate
once the files held install logic and launch arguments.

Verified on all ten CoD games (T4/T5/T6 MP+ZM, IW5, IW4x, T7x MP+ZM,
CoD4x): launch commands byte-identical to the originals, correct config
symlinks per engine, health and RCON working.

* feat(protocols): A2S, Source RCON and telnet, split by protocol

pyquake3.py was the only wire protocol Plutainer spoke, which was fine
while every game was Quake-derived. The SteamCMD games are not: 7DTD has
a telnet console and no RCON at all, Source games speak Valve's RCON over
TCP, and none of them answer getstatus.

Protocols now live in scripts/protocols/, split by protocol rather than
by game, because the mapping is many-to-many — Quake3 covers seven CoD
titles, A2S covers four Steam games across three engines, and querying is
a different concern from administering.

healthcheck.sh picks its probe from the family table (STEAM_QUERY) rather
than a hardcoded branch, and keeps the same bar for every game: the server
must name a map it is running. A2S reports one, so "healthy" does not
degrade to "a TCP connect succeeded" for the new family.

rcon-cli keeps its name because that is what people search for, but is now
one small class per protocol behind a dispatch table. It also distinguishes
"this game has no remote console" from "it has one and you have it switched
off", and tells you which setting to change in the second case.

Both try loopback and then the container's own address. Source 1 servers
answer only on the latter: an identical A2S query times out on 127.0.0.1
and replies immediately on the container IP, with the server healthy
throughout. Rather than encode which engines behave which way, try both.

pyquake3.py moves to protocols/quake3.py with its GPL attribution intact.

* fix(logs): let a family opt out of rotation, and skip huge install trees

Rotation is copy-truncate, which is only valid against a writer that
opened its log with O_APPEND — true of every CoD engine, and the reason
the existing implementation is safe. A Unity dedicated server's -logfile
writer keeps its own offset instead, so truncating would leave a sparse
hole and the file's apparent size would snap straight back over the limit,
re-triggering rotation on every poll.

That has not been measured against a running 7DTD, so PLUTAINER_LOG_ROTATE
lets the SteamCMD family turn rotation off. Not rotating is the safe
failure — an unbounded log, which is what the game does unmanaged — rather
than a rotation loop copying gigabytes every two seconds.

PLUTAINER_LOG_PRUNE_DIRS keeps the poller out of a SteamCMD install, which
is tens of thousands of files that would otherwise be walked every two
seconds for one log that does not live there.

* build: add SteamCMD, keep STOPSIGNAL SIGKILL

SteamCMD is fetched but deliberately not bootstrapped at build time.
Running `steamcmd.sh +quit` in the image pulls a few hundred MB of Steam
client into $HOME that every Call of Duty user would then carry forever,
and 340 MB of Xvfb was removed for exactly that reason. It also does not
reliably prevent the first-contact failure it appears to fix, which is
handled with a retry instead.

Placed after everything the CoD families need, so adding or bumping it
never invalidates their layers.

STOPSIGNAL stays SIGKILL. A game with world state to flush cannot be
served by a signal that cannot be trapped, but changing the image default
would alter shutdown for seven servers already running in production to
benefit one new family. Those games ask for SIGTERM per service instead:

    stop_signal: SIGTERM
    stop_grace_period: 90s

Measured: 7DTD forwards the signal, saves, and exits 0 in 3.5s; the same
image with no stop_signal stops in 254ms as before. The hang case needs no
code — Docker sends SIGKILL itself when the grace period expires.

chmod now covers every *.sh by find rather than a hand-maintained list
that silently rots as scripts move between directories.

* feat(steam): add CS2, L4D2 and Half-Life 2: Deathmatch

Three more games through the SteamCMD family, chosen to stress the table
in different directions rather than to pad the list. Each needed a table
row and, at most, one hook.

HL2:DM is the plain srcds case. L4D2 shares its hooks entirely. CS2 shares
seed, configure, stage and admin with them and overrides only its launch
arguments, because Source 2 has no srcds_run wrapper.

L4D2 needs a two-phase install and it is not optional. Valve restricted
anonymous Linux installation of app 222860: every depot including the
9.5 GB content one is flagged windows, so a plain app_update on Linux
fails with "Invalid platform". This is an open upstream issue
(ValveSoftware/steam-for-linux#11522) that takes LinuxGSM down with it, so
"this used to work" is true and not a local fault. Pulling the content as
Windows and re-running as Linux overlays the native binaries depot on top.
STEAM_INSTALL_PLATFORMS expresses that generically. The result is a genuine
native server — srcds_linux, no Wine, reports "os: Linux Dedicated".

CS2 exposed a real bug in the config handling. link_configs refuses to
replace a real file at the engine path, because for the CoD families that
file is the strongest signal of user intent. Inside a SteamCMD install the
same signal means the opposite: the directory is Steam's, so a real file is
a depot default that the next update restores anyway. CS2 ships a 33-byte
game/csgo/cfg/server.cfg reading "// Defaults in server_default.cfg", so
the fan-out skipped it with a warning and the server ran on stock settings
while app/configs/server.cfg sat there looking correct. Measured before
and after: hostname went from "Counter-Strike 2" to the configured name,
and the name a server browser shows changed with it.

SteamCMD's first contact in a fresh container is also unreliable — it
downloads its own client, re-execs, and an app_update issued before that
settles fails with "Missing configuration". Measured on both 7DTD and
HL2:DM, with the next attempt succeeding, so installs are retried.

Seed configs are hand-written rather than vendored: Valve ships no
server.cfg for any of these. All three ship empty rcon_password and
sv_password, like every other seed here.

* docs: rewrite for two families and eleven games

The docs described five families and a script-per-game layout that no
longer exists, and the 7DTD material from the original PR assumed it was
a one-off rather than the first member of a family.

Reworked around the two-platform model, with per-game specifics where a
reader looks for them: disk footprints (CS2 is 67 GB and will exceed the
health grace period on first start), the GSLT and masterserver-token
equivalence, why clean shutdown is opt-in per service, and what the
depot-config takeover does.

Audited rather than assumed: all fourteen game tags now appear in the
README table, the games reference, the configuration reference and a
working compose example; every environment variable the code reads is in
the reference table; every script is described in CLAUDE.md; no stale
references to the deleted entry scripts remain; all internal doc links
resolve. That audit found two undocumented variables, now added.

* chore: remove the EXAMPLE-docker-compose.yml signpost

The file had already been reduced to a stub pointing at examples/, kept on
the theory that older guides and forum posts link to it. Nothing in the
repo references it any more: every doc that shows a compose file points at
examples/ directly, so the stub was carrying its own rationale and nothing
else.

Also drops the two places that still named it — its .dockerignore entry
(examples/ and *.md already cover everything it excluded) and its
paths-ignore entry in the publish workflow, which was suppressing builds
for a file that no longer exists.

---------

Co-authored-by: Keyboard Sped <93077330+CoreyUK@users.noreply.github.com>
2026-08-16 17:12:31 +01:00

494 lines
19 KiB
Bash

#!/bin/bash
#
# Shared core library: volume paths, family detection, process lifecycle.
# Sourced by entrypoint scripts, healthcheck, and rcon-cli.
#
# There are exactly two families, and they are *platforms* rather than engines:
#
# cod Quake-derived servers Plutainer installs and runs itself
# (Plutonium, IW4x, Alterware, CoD4x). You supply the game files.
# steam Servers SteamCMD installs (7DTD, CS2, L4D2, HL2:DM). You supply
# nothing.
#
# Engine variation lives *below* the family, as a field in that family's game
# table, because it does not change how Plutainer treats the server: a
# Plutonium T6 and a CoD4x server differ far less from each other than either
# does from a SteamCMD install.
#
# Each family owns one file, one entry script, and one game table:
# lib/fs.sh symlink/mirroring helpers, used by both
# lib/cod.sh the cod family
# lib/steam.sh the steam family
#
# Volume layout (v2):
# /home/plutainer/app/
# configs/ # User-facing config files (flat).
# # Real files unless PLUTAINER_USE_RAW_CONFIGS=true.
# logs/ # Stable symlinks to active *.log files
# # (maintained by log-watcher.sh).
# runtime/
# gamefiles/ # Symlinks into host /home/plutainer/gamefiles
# # plus writable game state. [CoD families]
# plutonium/ # Plutonium binaries + storage state.
# steam/<game>/ # SteamCMD-managed install. [Steam family]
# gamedata/<game>/ # Worlds, saves, mods — never
# # touched by SteamCMD. [Steam family]
# .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"
# SteamCMD family. The install and the persistent data are kept apart on
# purpose: SteamCMD owns everything under steam/ and may delete or replace any
# of it on an update, so nothing the user cares about is allowed to live there.
PLUTAINER_STEAM_DIR="$PLUTAINER_RUNTIME_DIR/steam"
PLUTAINER_GAMEDATA_DIR="$PLUTAINER_RUNTIME_DIR/gamedata"
# 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 <container>" >&2
exec sleep infinity
}
# Run the game binary in the foreground, 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.
#
# The image's STOPSIGNAL is SIGKILL, so `docker stop` never reaches this
# function: docker kills the container outright and the CoD engines lose
# nothing by it. A game that DOES have state to flush on shutdown uses
# launch_game_graceful below instead, and asks for SIGTERM in its compose file.
# 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"
}
# As launch_game, but forwards SIGTERM to the game so it can shut down cleanly.
#
# Why this is separate rather than folded into launch_game: SIGKILL cannot be
# trapped, so a game that must flush state on stop can only be served by the
# container receiving SIGTERM — which means changing the image's STOPSIGNAL for
# everything, or letting the one game that needs it ask for SIGTERM per service:
#
# stop_signal: SIGTERM
# stop_grace_period: 90s
#
# The second costs one line in a compose file and changes nothing for the seven
# CoD servers people are already running, so that is what we do. Without those
# lines the container still stops correctly — instantly, without the clean save.
#
# The hang case needs no code: docker sends SIGKILL itself once
# stop_grace_period expires, so a game that ignores SIGTERM cannot wedge a stop.
#
# Args: command + its arguments.
launch_game_graceful() {
set +e
"$@" &
local game_pid=$!
local rc=0
local stopping=false
# `wait` is interrupted by a trapped signal even while the child lives, so the
# loop below re-enters it until the child has actually gone.
# shellcheck disable=SC2317
_forward_stop() {
stopping=true
trap - TERM INT
echo "[INFO] Stop requested — forwarding SIGTERM to ${GAME_NAME} (pid ${game_pid})."
kill -TERM "$game_pid" 2>/dev/null || true
}
trap _forward_stop TERM INT
while true; do
wait "$game_pid"
rc=$?
kill -0 "$game_pid" 2>/dev/null || break
done
trap - TERM INT
set -e
if [[ "$stopping" == "true" ]]; then
echo "[INFO] ${GAME_NAME} shut down cleanly."
exit 0
fi
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 from PLUTAINER_GAME.
# Returns 1 if unknown.
# Nothing is hardcoded here: each family owns its own game list, so adding a
# game is a one-file change that never has to be mirrored into the core.
derive_family() {
cod_is_known_game "$1" && { echo "cod"; return 0; }
steam_is_known_game "$1" && { echo "steam"; return 0; }
return 1
}
# --- Hooks ------------------------------------------------------------------
#
# Both families run one entry script driven by a game table, and express
# per-game behaviour as functions named <family>_<hook>_<suffix>. Suffixes are
# tried most-specific first, so a game inherits its engine's behaviour and
# overrides only what genuinely differs:
#
# cod_launch_t7x -> cod_launch_alterware
# steam_seed_cs2 -> steam_seed_srcds
#
# Run <family>_<hook>_<suffix> for the first suffix that exists; return 1 if
# none do.
plutainer_hook() {
local family="$1" hook="$2"
shift 2
local suffix fn
for suffix in "$@"; do
[[ -n "$suffix" ]] || continue
fn="${family}_${hook}_${suffix}"
if declare -F "$fn" >/dev/null 2>&1; then
"$fn"
return 0
fi
done
# Say so. Callers run under `set -e`, so a bare non-zero return here would
# kill the entry script with no output at all — which is exactly how a
# mis-passed suffix list once looked like a silent crash.
echo "[WARN] No ${family}_${hook}_* hook for: $*" >&2
return 1
}
plutainer_hook_exists() {
local family="$1" hook="$2"
shift 2
local suffix
for suffix in "$@"; do
[[ -n "$suffix" ]] || continue
declare -F "${family}_${hook}_${suffix}" >/dev/null 2>&1 && return 0
done
return 1
}
# Fail loudly at startup for a hook nobody implemented, instead of silently
# doing nothing at the point it was needed. Hook names are strings, so a typo is
# otherwise invisible until someone notices a server ignoring its config — which
# is exactly how the CS2 depot-config bug hid.
# Args: <family> <comma-separated suffixes> <required hook>...
plutainer_require_hooks() {
local family="$1" suffixes="$2"
shift 2
local -a candidates
IFS=',' read -ra candidates <<< "$suffixes"
local hook
local -a missing=()
for hook in "$@"; do
plutainer_hook_exists "$family" "$hook" "${candidates[@]}" || missing+=("$hook")
done
[[ ${#missing[@]} -eq 0 ]] && return 0
echo "[ERROR] ${GAME_NAME}: nothing implements hook(s): ${missing[*]}" >&2
echo " Looked for ${family}_<hook>_<suffix> with suffix in: ${suffixes}" >&2
echo " This is a bug in Plutainer, not a problem with your configuration." >&2
return 1
}
# Populate GAME_TYPE, GAME_NAME, BASE_GAME, CONFIG_FILE, CUSTOM_PORT,
# HEALTHCHECK_FLAG from PLUTAINER_*.
detect_game_type() {
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
}
# Cleared first so a value can never leak in from a previous resolve — these
# are globals, and healthcheck/rcon-cli resolve more than one thing per run.
BASE_GAME=""
COD_ENGINE=""
STEAM_ENGINE=""
ENGINE_CONFIG_DIR=""
MOD_CONFIG_DIR=""
case "$GAME_TYPE" in
cod) cod_resolve_game || return 1 ;;
steam) steam_resolve_game || return 1 ;;
esac
CONFIG_FILE="${PLUTAINER_CONFIG_FILE:-}"
CUSTOM_PORT="${PLUTAINER_PORT:-}"
HEALTHCHECK_FLAG="${PLUTAINER_HEALTHCHECK:-}"
}
# Set DEFAULT_PORT based on BASE_GAME (or the arg). SteamCMD games carry their
# port in the family table rather than here.
# Both families keep their default port in their game table, so this is a lookup
# through whichever table is active rather than a second copy of the data.
resolve_default_port() {
case "${GAME_TYPE}" in
cod) DEFAULT_PORT="$COD_DEFAULT_PORT" ;;
steam) DEFAULT_PORT="$STEAM_DEFAULT_PORT" ;;
*)
echo "[ERROR] Could not determine default port for '${GAME_NAME}'." >&2
return 1
;;
esac
}
# The port this server actually uses. Resolved in one place so the entry script,
# the health check and rcon-cli cannot disagree about it.
resolve_active_port() {
if [[ -n "${CUSTOM_PORT:-}" ]]; then
if [[ ! "$CUSTOM_PORT" =~ ^[0-9]+$ ]] || (( CUSTOM_PORT < 1 || CUSTOM_PORT > 65535 )); then
echo "[ERROR] PLUTAINER_PORT must be a port number between 1 and 65535, got '${CUSTOM_PORT}'." >&2
return 1
fi
ACTIVE_PORT="$CUSTOM_PORT"
else
resolve_default_port || return 1
ACTIVE_PORT="$DEFAULT_PORT"
fi
}
# Resolve CONFIG_PATH (and the surrounding CONFIG_SOT_DIR/ALT_CONFIG_DIR) for
# whichever family is active. This is the entry point healthcheck.sh and
# rcon-cli use; entry scripts call the family functions directly.
resolve_config_path() {
case "${GAME_TYPE}" in
cod) cod_resolve_config_path ;;
steam) steam_resolve_config_path ;;
esac
}
# Addresses worth trying when talking to our own server, in order.
#
# Loopback is right for everything except Source dedicated servers, which bind
# their query and RCON sockets to the container's interface address and simply
# do not answer on 127.0.0.1 — measured on HL2:DM: identical A2S query, timeout
# on loopback, full reply on the container IP, with the server perfectly
# healthy throughout. Rather than encode which engines behave which way, try
# loopback first (cheap, and correct for every other family) and fall back.
# `ip` rather than `hostname -i`: iproute2 is already a dependency here (the T5/T6
# gateway detection uses it) whereas Arch's `hostname` comes from inetutils,
# which this image does not install.
plutainer_query_hosts() {
local -a hosts=("127.0.0.1")
local addr
while read -r addr; do
[[ -n "$addr" && "$addr" != "127.0.0.1" ]] && hosts+=("$addr")
done < <(ip -4 -o addr show scope global 2>/dev/null | awk '{split($4, a, "/"); print a[1]}')
echo "${hosts[@]}"
}
# Resolve how rcon-cli should talk to this server, setting:
#
# ADMIN_PROTOCOL quake3 | source-rcon | telnet | none
# ADMIN_PORT TCP or UDP port, per protocol
# ADMIN_PASSWORD credential, possibly empty
# ADMIN_HOSTS addresses to try, in order (see plutainer_query_hosts)
#
# "none" is a legitimate answer — a game with no remote console at all — and
# rcon-cli says so rather than failing obscurely. Returns non-zero only if the
# game itself could not be resolved.
resolve_admin_endpoint() {
ADMIN_PROTOCOL="none"
ADMIN_PORT=""
ADMIN_PASSWORD=""
ADMIN_HOSTS="$(plutainer_query_hosts)"
case "${GAME_TYPE}" in
cod) cod_resolve_admin_endpoint ;;
steam) steam_resolve_admin_endpoint ;;
esac
}
# 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
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
V1_VOLUME_DETECTED=true
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"
}
# 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 <YOUR_APP_VOLUME>:/home/plutainer/app \
--entrypoint /home/plutainer/.plutainer/migrate-v1-to-v2.sh \
ghcr.io/ayymoss/plutainer:v2
<YOUR_APP_VOLUME> 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
}
# --- Family libraries -------------------------------------------------------
#
# Sourced last so they can rely on everything above. Both are always loaded:
# healthcheck.sh and rcon-cli dispatch on GAME_TYPE at runtime and need either
# set available. Keeping them in separate files is about where code is allowed
# to live, not about loading less of it — a CoD change should never require
# reading the Steam helpers, and vice versa.
PLUTAINER_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$PLUTAINER_LIB_DIR/fs.sh"
source "$PLUTAINER_LIB_DIR/cod.sh"
source "$PLUTAINER_LIB_DIR/steam.sh"
# Every game tag this image accepts, for error messages. Assembled after the
# family libraries load so neither list has to be restated here.
PLUTAINER_KNOWN_GAMES="$(printf '%s, ' "${COD_GAMES[@]}" "${STEAM_GAMES[@]}" | sed 's/, $//')"