* 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>
iw4x/launcher#76 is fixed upstream — pregenerated sources regenerated to
ODB_VERSION 20600 and libodb pinned to == 2.6.0 — so the arm64 source build
should work again. A rebuild would not have picked it up.
Both Dockerfiles fetch the launcher from inside a RUN, so the layer cache key
is just the command string and never changes. BuildKit would reuse the cached
layer and ship the same artifact regardless of what upstream did. On arm64
that is worse than stale: the `|| { ... }` fallback makes the step exit 0 even
when the compile fails, so the *failed* build was cached as a success and
would have kept emitting the .unavailable marker forever. amd64 has the same
latent bug in milder form — it resolves releases/latest inside a cached RUN,
so the image could ship a launcher many releases out of date.
CI now resolves the upstream ref per architecture (release tag for amd64's
prebuilt binary, commit SHA for arm64's source build) and passes it as
IW4X_LAUNCHER_REF. The arg is only there to move the cache key; the fetch
still uses releases/latest and #main respectively, so there is no behaviour
change beyond cache invalidation.
No change needed to the degradation path itself: iw4xentry.sh checks for the
binary rather than the architecture, so arm64 regains iw4x support on its own
once a build produces one.
Filed iw4x/launcher#76 for the pregenerated-ODB version mismatch that
breaks the arm64 source build, and referenced it from CLAUDE.md so the
blocker is discoverable from the repo rather than only from CI logs. Also
recorded that cpp-builder is the only failing arm64 stage — plutonium-updater,
Wine and the T7x path all build fine — so one component currently blocks
the whole arm64 image, along with the graceful-degradation option if
upstream stalls.
CLAUDE.md joins paths-ignore. It is never COPYied into the image, so a
docs-only edit has no reason to spend a ~20 minute cold arm64 build.
Three separate problems with the pipeline, all surfaced by shipping the
iw4x launcher fixes:
1. arm64 blocked every release. It has no upstream iw4x-launcher binary so
it compiles from source, and it currently fails on upstream's stale
pregenerated ODB sources (guard on ODB_VERSION 20551 / odb 2.6.0-b.51
while their own manifest asks for libodb >= 2.6.0- and odb 2.6.0 final
is now published). That is not pinnable from here: bpkg reads the
manifests from the remote repo, so patching a clone changes nothing.
Mark arm64 optional + continue-on-error so amd64 still publishes, and
have merge warn loudly instead of silently shipping a partial manifest.
amd64 stays required — no amd64 digest, no publish.
2. Nothing was testable until arm64 finished. Publish :edge straight from
the amd64 job. Single-platform on purpose, documented as such.
3. Cold builds kept recurring. The GHA cache evicts after 7 idle days, so
any gap longer than a fortnight meant recompiling the build2 toolchain
(~20 min vs ~3 min). A weekly cron keeps it alive.
merge now downloads digests without merge-multiple so one subdirectory per
platform survives, which is what lets it detect coverage; the manifest is
assembled with find rather than a cwd glob to match.
Docs: correct the stale Tags section (v2-layout branch is long gone, main
publishes both :latest and :v2), document the arm64 situation and the
upstream root cause, and note in the README that IW4x has no bundled config
seed — the container refuses to start until you supply one, so point at
iw4x/iw4-server-configs and flag its commented-out sv_maprotation. Also
record which iw4x files the launcher owns, so nobody stages them in the
read-only gamefiles mount.
Replaces the two single-arch workflows with the standard multi-arch
pattern from the Docker docs: matrix-build per platform on its native
runner (push by digest, no human tag), then a merge job that stitches
the digests into a single multi-arch manifest published as :latest, :v2,
and any release/PR/sha tags.
Side effects:
- GHCR users on arm64 hosts now pull :latest and get arm64 automatically.
No more :arm64 / :arm64-v2 manual tag selection.
- Attestation manifests (the untagged sha256: entries on the package
page) are disabled via provenance: false / sbom: false.
- :sha-XXX tag is now a single multi-arch manifest, not two single-arch
tags (:sha-XXX, :sha-XXX-arm64).
Also bumps actions to versions that target Node 24 ahead of the
2026-06-02 default switch and 2026-09-16 removal of Node 20:
- actions/checkout v4 → v5
- docker/build-push-action v5 → v6
- (docker/login-action v3, docker/setup-buildx-action v3, and
docker/metadata-action v5 patch releases are already Node 24-ready.)
* v2: unify PLUTAINER_* env vars, restructure volume layout, harden entries
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.
* 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.
* 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-<short> tag.
Doc references updated from :main to :v2.
* v2: drop env-var shim; add raw-configs mode, auto-lift, hold/throttle, mod fan-out
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.
* 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.
* 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.
* 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.
* 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/<repo>: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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v2: combined v1-deployment refusal + MIGRATION.md + workflow for :latest promotion
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.
---------
Co-authored-by: nellydocs <71311423+nellydocs@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>