mirror of
https://github.com/Ayymoss/Plutainer
synced 2026-08-24 14:32:06 -04:00
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
* 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>
280 lines
12 KiB
Bash
280 lines
12 KiB
Bash
#!/bin/bash
|
|
#
|
|
# Maintains stable symlinks at /home/plutainer/app/logs/<basename> pointing at
|
|
# the active game log for that basename. Game logs move around per game/mod
|
|
# (e.g. plutonium/storage/t5/mods/<mod>/logs/games_zm.log) and users create
|
|
# arbitrarily named logs (e.g. games_koth.log), which makes IW4MAdmin
|
|
# configuration brittle. This watcher surfaces every *.log under app/ in one
|
|
# predictable flat directory.
|
|
#
|
|
# Strategy:
|
|
# - Record container boot time.
|
|
# - Poll app/ for every *.log (excluding app/logs/ itself to avoid cycles).
|
|
# - For each basename, the active log is the one with mtime >= boot time and
|
|
# the newest mtime overall. Stale logs from prior sessions keep their old
|
|
# mtime and are ignored. Collisions across mod dirs resolve to the
|
|
# currently-written file (game only writes one at a time).
|
|
# - Symlinks are relative so they resolve the same from host, this
|
|
# container, or any sidecar container mounting the app/ volume.
|
|
# - Only repoint when target changes (idempotent, no fs churn).
|
|
# - Startup heal: dangling symlinks from prior runs (target removed between
|
|
# restarts) are converted to empty stub files. Preserves presence so a
|
|
# sidecar like IW4MAdmin never sees a missing path and doesn't attempt to
|
|
# create a directory in its place.
|
|
# - Atomic repointing via mv -Tf from a temp symlink so readers never catch
|
|
# the link in a missing or half-written state.
|
|
#
|
|
|
|
APP_DIR=/home/plutainer/app
|
|
STABLE_DIR="$APP_DIR/logs"
|
|
POLL_INTERVAL="${PLUTAINER_LOG_POLL_INTERVAL:-2}"
|
|
MAX_SIZE_RAW="${PLUTAINER_LOG_MAX_SIZE:-64M}"
|
|
KEEP="${PLUTAINER_LOG_KEEP:-1}"
|
|
|
|
if [[ "${PLUTAINER_LOG_SYMLINKS}" == "false" ]]; then
|
|
echo "[log-watcher] disabled via PLUTAINER_LOG_SYMLINKS=false"
|
|
exit 0
|
|
fi
|
|
|
|
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:
|
|
# - 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"
|
|
touch "$entry"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# --- Log rotation -----------------------------------------------------------
|
|
#
|
|
# Game logs grow without bound, and a large enough one stops the server logging
|
|
# at all: CoD4x's writer hands each buffered chunk to fwrite() and, when that
|
|
# fwrite fails, prints a warning and drops the data — the ring buffer has
|
|
# already been advanced. Nothing retries and nothing reopens the file, so once
|
|
# the write starts failing the log is dead for the life of the process. A
|
|
# log-driven admin tool (IW4MAdmin reads in-game commands from the log) then
|
|
# looks like it has stopped responding, even though RCON still works.
|
|
#
|
|
# Rotation is copy-truncate, which is safe here because every engine opens its
|
|
# game log in append mode — CoD4x is literally fopen(path, "ab"). With O_APPEND
|
|
# each write seeks to EOF first, so after truncation writes resume at offset 0.
|
|
# Verified on all eleven server types: none returned to its previous size, which
|
|
# is what a non-append writer would have done by leaving a sparse hole.
|
|
#
|
|
# Truncating rather than renaming is deliberate: the engine holds an open handle
|
|
# and renaming would leave it writing to the renamed inode, so the path the
|
|
# admin tool reads would silently stop updating — the exact failure being fixed.
|
|
#
|
|
# Worst-case disk per log is MAX_SIZE x (KEEP + 1), so the defaults (64M, keep
|
|
# 1) cost at most ~128 MB per server. That matters at scale: a community with
|
|
# thirty servers rotating at a gigabyte would be carrying tens of gigabytes of
|
|
# logs nobody reads. Live tailing only ever needs the tail, and an admin tool
|
|
# keeps its own history in its database.
|
|
#
|
|
# Set PLUTAINER_LOG_MAX_SIZE=0 to disable, or PLUTAINER_LOG_KEEP=0 to rotate
|
|
# without keeping a copy at all.
|
|
parse_size() {
|
|
local raw="${1^^}" num unit
|
|
num="${raw%[KMG]}"
|
|
unit="${raw#"$num"}"
|
|
[[ "$num" =~ ^[0-9]+$ ]] || { echo ""; return 1; }
|
|
case "$unit" in
|
|
K) echo $(( num * 1024 )) ;;
|
|
M) echo $(( num * 1024 * 1024 )) ;;
|
|
G) echo $(( num * 1024 * 1024 * 1024 )) ;;
|
|
"") echo "$num" ;;
|
|
*) echo ""; return 1 ;;
|
|
esac
|
|
}
|
|
|
|
MAX_SIZE=$(parse_size "$MAX_SIZE_RAW") || {
|
|
echo "[log-watcher] WARN: cannot parse PLUTAINER_LOG_MAX_SIZE='$MAX_SIZE_RAW'; rotation disabled." >&2
|
|
MAX_SIZE=0
|
|
}
|
|
|
|
# Copy-truncate is only valid against a writer that opened its log with
|
|
# O_APPEND, which every CoD engine here does. A writer that tracks its own file
|
|
# offset — a Unity dedicated server's -logfile, for one — would carry on writing
|
|
# at the old offset after truncation, leaving a sparse hole and an apparent size
|
|
# that snaps straight back over the limit, so rotation would fire on every poll.
|
|
# Families that cannot prove their writer appends set PLUTAINER_LOG_ROTATE=false.
|
|
if [[ "${PLUTAINER_LOG_ROTATE:-true}" == "false" ]]; then
|
|
MAX_SIZE=0
|
|
ROTATE_DISABLED_REASON=" (PLUTAINER_LOG_ROTATE=false)"
|
|
fi
|
|
|
|
rotate_if_oversized() {
|
|
local path="$1" size
|
|
(( MAX_SIZE > 0 )) || return 0
|
|
|
|
size=$(stat -c %s "$path" 2>/dev/null) || return 0
|
|
(( size >= MAX_SIZE )) || return 0
|
|
|
|
echo "[log-watcher] $(basename "$path") reached ${size}B (limit ${MAX_SIZE}B) — rotating"
|
|
|
|
if (( KEEP > 0 )); then
|
|
# Shift older generations down: .2 -> .3, .1 -> .2, ...
|
|
local i
|
|
for (( i = KEEP - 1; i >= 1; i-- )); do
|
|
[[ -f "$path.$i" ]] && mv -f "$path.$i" "$path.$((i + 1))" 2>/dev/null
|
|
done
|
|
if ! cp -f "$path" "$path.1" 2>/dev/null; then
|
|
echo "[log-watcher] WARN: could not copy $path aside; truncating without a backup." >&2
|
|
fi
|
|
# Drop anything beyond the keep count.
|
|
for old in "$path".*; do
|
|
[[ "$old" =~ \.([0-9]+)$ ]] || continue
|
|
(( BASH_REMATCH[1] > KEEP )) && rm -f "$old"
|
|
done
|
|
fi
|
|
|
|
# Truncate in place. The engine's next append lands at offset 0.
|
|
: > "$path" || echo "[log-watcher] WARN: could not truncate $path" >&2
|
|
|
|
# Immediately write a marker so the file is never observed at zero bytes.
|
|
#
|
|
# This is not cosmetic. IW4MAdmin tracks its read position as a byte offset
|
|
# and treats zero as "no position yet":
|
|
#
|
|
# if (_previousFileSize == 0) { _previousFileSize = fileSize; }
|
|
# var fileDiff = fileSize - _previousFileSize;
|
|
# if (fileDiff < 1 ...) { _previousFileSize = fileSize; return; }
|
|
#
|
|
# After a truncation its offset resets to 0, and the next poll that sees the
|
|
# file grow re-syncs to the new size *instead of reading it* — so the first
|
|
# batch of events written after a rotation is silently dropped. Measured: with
|
|
# a bare truncate, the first map change after rotation was never read and the
|
|
# second was. Leaving one line behind keeps the offset non-zero, so the very
|
|
# next batch is read normally.
|
|
#
|
|
# The text is deliberately plain and semicolon-free: game log parsers key off
|
|
# `say;`/`J;`/`K;` style prefixes, so this parses as nothing and is ignored.
|
|
printf ' 0:00 [Plutainer] log rotated - previous %s bytes kept alongside\n' \
|
|
"$size" >> "$path" 2>/dev/null || true
|
|
}
|
|
|
|
# Put one line into a brand-new, empty game log, for the same reason the rotation
|
|
# path writes a marker: a reader that treats a zero offset as "no position yet"
|
|
# re-syncs on the first growth instead of reading it, so the first batch of
|
|
# events after the log is created gets dropped. That is every event of the first
|
|
# map on a fresh deployment.
|
|
#
|
|
# Measured with IW4MAdmin across eleven servers: the eight whose logs already had
|
|
# content ingested the first injected event; the three whose logs were empty did
|
|
# not, and ingested the next one. Priming removes that asymmetry.
|
|
prime_empty_log() {
|
|
local path="$1"
|
|
[[ -f "$path" ]] || return 0
|
|
[[ ! -s "$path" ]] || return 0
|
|
printf ' 0:00 [Plutainer] log opened\n' >> "$path" 2>/dev/null || true
|
|
}
|
|
|
|
BOOT_TS=$(date +%s)
|
|
|
|
declare -A CURRENT_TARGET
|
|
|
|
echo "[log-watcher] started; boot_ts=$BOOT_TS stable_dir=$STABLE_DIR"
|
|
if (( MAX_SIZE > 0 )); then
|
|
echo "[log-watcher] rotating game logs above ${MAX_SIZE_RAW} (keeping ${KEEP})"
|
|
else
|
|
echo "[log-watcher] log rotation disabled${ROTATE_DISABLED_REASON:-}"
|
|
fi
|
|
|
|
# Directories to keep the poller out of, colon separated. A SteamCMD install is
|
|
# tens of thousands of files and gets walked every POLL_INTERVAL seconds
|
|
# otherwise, for logs that belong to the game's own installer rather than to a
|
|
# running server.
|
|
declare -a PRUNE_ARGS=( -path "$STABLE_DIR" -prune )
|
|
if [[ -n "${PLUTAINER_LOG_PRUNE_DIRS:-}" ]]; then
|
|
IFS=':' read -ra _prune_dirs <<< "$PLUTAINER_LOG_PRUNE_DIRS"
|
|
for _dir in "${_prune_dirs[@]}"; do
|
|
[[ -n "$_dir" ]] || continue
|
|
PRUNE_ARGS+=( -o -path "$_dir" -prune )
|
|
echo "[log-watcher] not scanning $_dir"
|
|
done
|
|
fi
|
|
|
|
while true; do
|
|
declare -A NEWEST_MTIME=()
|
|
declare -A NEWEST_PATH=()
|
|
|
|
while IFS= read -r -d '' path; do
|
|
mtime=$(stat -c %Y "$path" 2>/dev/null) || continue
|
|
(( mtime < BOOT_TS )) && continue
|
|
# Only the live log is worth rotating; stale logs from earlier sessions are
|
|
# already excluded by the boot-time check above.
|
|
rotate_if_oversized "$path"
|
|
prime_empty_log "$path"
|
|
name=$(basename "$path")
|
|
if (( mtime > ${NEWEST_MTIME[$name]:-0} )); then
|
|
NEWEST_MTIME[$name]=$mtime
|
|
NEWEST_PATH[$name]=$path
|
|
fi
|
|
done < <(find "$APP_DIR" "${PRUNE_ARGS[@]}" -o -type f -name '*.log' -print0 2>/dev/null)
|
|
|
|
for name in "${!NEWEST_PATH[@]}"; do
|
|
path="${NEWEST_PATH[$name]}"
|
|
if [[ "$path" != "${CURRENT_TARGET[$name]:-}" ]]; then
|
|
link="$STABLE_DIR/$name"
|
|
rel_target=$(realpath --relative-to="$STABLE_DIR" "$path")
|
|
place_symlink "$link" "$rel_target"
|
|
CURRENT_TARGET[$name]=$path
|
|
echo "[log-watcher] $name -> $rel_target"
|
|
fi
|
|
done
|
|
|
|
unset NEWEST_MTIME NEWEST_PATH
|
|
sleep "$POLL_INTERVAL"
|
|
done
|