* 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>
"No key" was misleading. CoD4x has no Plutonium-style key, but a public server
does need a masterserver token: without one it cannot register, and an
unregistered server never appears in the in-game server browser, so in practice
nobody finds it. Only players given the address can connect directly. Host
migration needs one too.
The engine documents the source itself - sv_authtoken is registered with the
description "Token to register on masterserver. You can get it from
http://cod4master.cod4x.ovh" - and validates it at 32 characters. Both are now
in the docs, along with what actually happens when it is absent.
Corrected in the README game table, the games at-a-glance table, the quickstart
prerequisites, the variable reference, the troubleshooting entry, and both
examples. IW4x and T7x really do need nothing and are left as they were.
The README had grown to 388 lines of reference material and was reported as too
difficult to follow. It is now a 72-line landing page - a working compose block,
the game table, and an index - with the detail moved to whichever page owns it:
docs/quickstart.md first server, start to finish
docs/games.md per game: files, keys, config names, quirks
docs/configuration.md environment variable reference
docs/volumes-and-configs.md volume layout, config symlinks, logs
docs/rcon.md passwords, rcon-cli, who may send RCON
docs/iw4madmin.md sidecar setup, parsers, the whitelist trap
docs/healthcheck.md what healthy means, restarts, autoheal
docs/troubleshooting.md symptom-first FAQ
Nothing was dropped; the length came out of duplication. The troubleshooting
page is deliberately symptom-first, and every entry is a failure that actually
happened - the T5 key that presents as a broken server, the CoD4x RCON password
silently refused for being under 8 characters, the IW4MAdmin whitelist.
Examples are split by scenario under examples/, replacing the single 228-line
file: single-server, per-game (a block for all seven), multi-server,
with-iw4madmin, and env.example for secrets. EXAMPLE-docker-compose.yml stays as
a signpost because forum posts link to it.
The IW4MAdmin example and docs now mount the log FILE rather than the app
directory. Pointing IW4MAdmin at Plutainer's logs/ symlink means it silently
ingests nothing: it decides whether to read by comparing the file's size, and
.NET reports a symlink's size as the length of the link text, so the log never
appears to grow. Mounting the file makes Docker resolve the symlink at mount
time. Verified across all eleven servers.
docs/ and examples/ are excluded from the build context.
Six pieces of work that turned out to share a lot of ground.
T7x no longer needs Xvfb. Passing -headless makes it skip Sys_CreateConsole and
print to stdout instead of building a Win32 console window, which is the only
reason a display was ever required. Verified against a clean volume; the images
now ship no X server at all, saving ~340MB.
Seed configs are vendored under seed-configs/ instead of being wget'd from six
third-party repos at build time. Any one of those disappearing broke the build
for every game, and alterware/t7x 404'd while this was being written. The RUN
string never changed either, so BuildKit cached the layer indefinitely and
upstream edits stayed invisible. tools/refresh-seeds.sh pins each repo to a
commit, records it in seed-configs/<game>/SOURCE, and blanks the placeholder
credentials upstream ships (an RCON password everyone knows, and in cod4x's case
a g_password that locked players out).
The healthcheck no longer needs an RCON password. It asks the server for its
status and requires a loaded map, trying getstatus then getinfo: IW5 and T5 only
answer the latter, while T7x's getinfo reports the lobby's map rather than the
running one. Every bundled config ships rcon_password empty, so the old
RCON-based check could never pass on a first run. pyquake3 is trimmed to the two
paths actually used, and now tolerates the stray byte T7x prefixes to its
statusResponse - which is why RCON never worked on T7x at all.
T5 and T6 gate unauthenticated getinfo on the RCON whitelist, so a sidecar
IW4MAdmin completes the RCON handshake and then fails on unanswered getinfo. The
sender is the container's Docker gateway, assigned at runtime, so it cannot be
written into a config file: resolve_rcon_whitelist_args detects it and passes
+rconWhitelistAdd. PLUTAINER_RCON_PASSWORD writes a password into the config,
opt-in and never destructive.
CoD4x is new, and is the one family that does not use Wine - upstream ships a
native Linux server, and the Windows build dies at window creation exactly as
T7x did. That makes it amd64-only, since the binary is 32-bit x86. Two assets
come from the client release because the server release has no fastfiles.
Game logs are rotated at 64MB. A large enough log stops CoD4x logging
permanently: the writer hands each chunk to fwrite, and on failure prints a
warning and drops the data with the buffer already advanced. Rotation is
copy-truncate, safe because every engine opens its log with fopen(path, "ab").
A marker line is written after truncating, and into any empty log, because
IW4MAdmin treats a zero offset as "no position yet" and re-syncs instead of
reading - which silently swallowed the first batch of events after each
rotation, and the first map's events on every fresh deployment.
Also fixes the arm64 healthcheck start period, which was 1m against amd64's 5m
and would have marked a first-run IW4x download unhealthy mid-download, and
bumps Hangover 11.4 -> 11.9.
The iw4x sections had grown into a changelog of how each problem was found
rather than documentation of how the thing works. Cut the debugging history,
dates and issue-resolution commentary; kept the facts that change what a
reader should do.
Corrects a wrong claim: CLAUDE.md said iw4x/launcher#76 was "resolved
upstream", which read as arm64 being fixed. Upstream did fix the issue, but
our arm64 build still fails and still ships the .unavailable marker, so iw4x
remains unsupported there. Both files now state that plainly.
Adds the one genuinely undocumented thing: zone/patch/ and zone/zonebuilder/
inside the app volume are updater-owned. Colliding filenames are overwritten
on update, and a symlink there stops the launcher extracting at all, which
silently disables updates.
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.
One component was blocking an entire architecture. cpp-builder exists only
to compile iw4x-launcher and is the sole failing arm64 stage — Wine,
plutonium-updater and the T7x path all build fine — yet its failure took
Plutonium t4/t5/t6/iw5 and Alterware t7x down with it and left :latest
amd64-only.
Dockerfile.arm64 now lets that build fail: it records a marker and writes
/out/iw4x-launcher.unavailable in place of the binary. Stage 3 copies /out/
as a directory rather than a file, since a file COPY of a missing path would
abort the build and reintroduce the coupling; the marker also guarantees the
directory is never empty.
iw4xentry.sh tests -x on the launcher and, when it is missing,
hold_indefinitely's with an explanation naming iw4x/launcher#76 and pointing
at the games that do work. Deliberately a capability check rather than an
architecture check, so IW4x on arm64 recovers on its own once an image ships
a working binary — no code change needed when upstream fixes it.
Also: add the iw4x seed to Dockerfile.arm64. It was added to Dockerfile only,
so the arm64 image would have shipped without it as soon as arm64 built
again — including the sv_maprotation append. Both files now note that the
seed blocks are duplicated and must be kept in sync.
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.
IW4x was the only supported game with no seed bundle, so a fresh volume hit
the "config file not found" refusal and parked in sleep infinity until the
user hand-placed a cfg. Every other game scaffolds itself on first start.
Seed from iw4x/iw4-server-configs, the upstream default, with
cfg_root_rel="userraw": its top-level userraw/*.cfg (server, serverlan,
partyserver, partyserverlan) lift into configs/, and the playlist *.info
files stay under runtime/gamefiles/userraw/ where the engine reads them.
Same shape as the t7x bundle.
One deliberate deviation. Every other seed here ships an active
sv_maprotation; iw4x's upstream config leaves it commented out, so a first
run would boot with +map_rotate and nothing to load. The image appends a
stock-MW2 rotation to server.cfg and serverlan.cfg, marked with an
"Added by Plutainer" comment block, skipped if upstream ever adds its own.
Stock maps only, so it does not depend on the DLC fastfiles. partyserver*
are left alone since lobby mode selects maps from a playlist instead.
Not changed: rcon_password stays empty, matching every other seed in the
image. A seeded server therefore plays fine but reports unhealthy until one
is set, since the healthcheck sends an RCON status. Documented in the README
rather than inventing a default, which would be a worse surprise.
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.
The previous comment claimed the DLC component writes to iw3/zone/dlc and
never touches zone/dlc. Verified on a live deployment that this is wrong:
the cdn manifest declares iw3/zone/dlc/<name>.ff but the launcher strips
the prefix, and all 35 files it downloaded landed in zone/dlc/.
The real distinction is how each component applies files. rawfiles unpacks
release.zip by writing straight through the destination path, so a symlink
there resolves into the read-only mount and fails. Every other component
stages and renames, which replaces a symlink instead of following it —
which is why mirroring zone/dlc works while mirroring zone/patch did not.
Mirroring zone/dlc is also actively useful: the reconciler hash-validates
the host's existing fastfiles, and skipped 46 of 81 DLC maps on this
deployment rather than re-downloading them.
Comments and docs only; no behaviour change.
Mirroring the host's zone/ still broke the launcher, just one level lower.
Making the directories real was not enough: the leaf entries were symlinks
into the read-only gamefiles mount, so extracting through them still hit
EROFS and died at the same place.
[E] exception caught in main: failed to extract file:
zone/patch/iw4_credits_load.ff
The mistake was treating zone/ as host-owned. The rawfiles manifest writes
every file under zone/patch/ and zone/zonebuilder/, so those belong to the
launcher and must not be pre-populated at all. Checked before ceding them:
its 56 zone/patch entries are a strict superset of a full MW2 install's 39
(zero host-only files), and zone/zonebuilder is the same lone
zonebuilder_minigun.ff it ships. Only zone/english and zone/dlc are
mirrored now — the host's map fastfiles, which the launcher never writes,
since its DLC component targets iw3/zone/dlc/ instead.
link_dir_contents now accepts a nested name so one subtree can be mirrored
without disturbing its siblings, and replaces a symlink at every component
of the destination path rather than only the last: upgrading from an image
that symlinked `zone` itself would otherwise make mkdir -p follow the link
into the read-only mount and abort startup under set -e.
Consequence of the original bug, for the record: the crash aborted the run
before sync_dlc and sync_helper, and left rawfiles unstamped, so iw4x
client updates never applied and the server was pinned to r5046 forever.
`mkdir -p` succeeds silently on an existing symlink-to-a-directory, so the
recursive mirror left a stale read-only link from an older image in place.
An existing volume would keep its zone/patch -> gamefiles symlink across a
container recreate and the launcher would keep failing to extract into it,
meaning the previous commit fixed fresh volumes but not upgrades.
Remove a symlink at each mirrored level before creating the real directory.
find walks parents before children, so a replaced directory always exists
before anything nested inside it.
link_dir_contents only symlinked the top level of the source dir. Host
zone/ contains nothing but directories, so zone/patch ended up a symlink
to the read-only gamefiles mount and the launcher died extracting into it:
[E] exception caught in main: failed to extract file:
zone/patch/iw4_credits_load.ff
Found on a real deployment. It presented as a healthy server because the
host's zone/patch/ already held iw4x's .ff files from a previous
bare-metal install, so the game loaded them anyway. On clean stock MW2
gamefiles the patch zones would simply be absent. The crash also aborted
the launcher before sync_dlc and sync_helper, and left release.zip
unextracted with rawfiles never stamped, so every subsequent start failed
at the same point and update checks stayed wedged.
Recreate every directory level as a real dir and symlink only leaf
entries. Also never overwrite a real file already at the destination: the
launcher may have written a newer copy there and it must win over the
host's version, which also keeps re-runs idempotent.
The iw4x-launcher was rewritten (Rust -> C++/build2) and dropped --path.
It now canonicalises /proc/self/exe and treats its own directory as the
installation root, ignoring cwd, so the current invocation fails outright
and the cache marker we probe for never appears.
Run a copy of the binary from inside the volume so the install root is
runtime/gamefiles/. This has to be a real cp, not a symlink: canonical()
would resolve a symlink back to .plutainer/ (an image layer) and the
~800MB of game data would be re-downloaded on every container recreate.
Verified against launcher source rather than observed behaviour:
- launcher.cli no --path in the option list
- launcher.cxx:1064-67 install root == cwd == binary dir
- cache-database.hxx:34-35 cache/iw4x.db, not launcher/cache.json
- cache-reconciler.cxx:888 clean() only prunes its own DB-tracked
files, so our symlinks are safe
Also:
- userraw/ and zone/ become real dirs with host contents symlinked in.
userraw/ is ENGINE_CONFIG_DIR, so a directory symlink to the read-only
mount made link_configs fail under set -e; zone/ has to be writable
for the launcher's zone/patch/*.ff.
- usermaps/ linked so custom maps in playlists resolve.
- Launcher failure is now only fatal on first run, so a GitHub or CDN
outage can't take a working server down.
- PLUTAINER_MAP_ROTATE=false drops the map-rotate arg, which otherwise
overrides playlist-driven map selection. Applied family-wide rather
than iw4x-only, since Plutonium takes the same arg.
Add link_dir_contents() for the real-dir-plus-symlinked-contents pattern;
alterentry.sh's inlined zone/ handling now uses it too.
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>
Image now ships default configs from upstream community repos and copies
them into the bind mount on container start. cp -n means existing user
files are never touched. Cuts the first-run friction of "compose up,
server fails to find config, look up which folder, find the right
example, copy it in, retry."
Sources bundled at build time:
- xerxes-at/T{4,5,6}ServerConfig* → app/gamefiles/main + plutonium/storage/{t5,t6}
- xerxes-at/IW5ServerConfig → app/gamefiles/admin
- Dss0/t7-server-config → app/gamefiles/{zone,t7x}
Disable per-stack with PLUTO_SKIP_SEED=true or ALTER_SKIP_SEED=true.
The check inverted its own semantics: setting *_HEALTHCHECK=true would
disable the health check (the documented behaviour was the opposite).
Flip the condition so "false" disables and any other value (including
default unset) leaves it enabled.
Docker 29.4.2 default seccomp blocks socketcall(2) entirely as part of
the CVE-2026-31431 (Copy Fail) mitigation. Old WoW64 wine on Debian
forks a 32-bit ELF wineloader that uses socketcall on i386, which fails
with "socket: Function not implemented" before the bootstrapper can
launch.
Arch ships pure-WoW64 wine: 32-bit Win apps run inside a single 64-bit
Wine process and call modern 64-bit Linux socket syscalls directly,
which the default Docker seccomp profile permits. No user compose
changes needed; drops i386 multilib for a ~1.5 GB smaller image.
Users should provide their own t7x/lobby_scripts/ folder from the
official example configs rather than having the container bundle it.
Added README note pointing to https://github.com/Dss0/t7-server-config.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Re-enable IW4x: download launcher via GitHub API (asset names now vary
per release), use --skip-launch/--no-self-update flags, extract with
--strip-components=1 for versioned directory structure
- Add lobby_init.lua for T7x: without this Lua script, sv_lobby_mode is
ignored and CreateDedicatedModsLobby defaults to MP mode
- Add xz-utils and jq as build dependencies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
T7x detects server mode via: has_flag("dedicated") || (!has_client && has_server).
Under Wine, flag detection via GetCommandLineW() is unreliable, so we only
symlink BlackOps3_UnrankedDedicatedServer.exe (not BlackOps3.exe) to guarantee
the fallback path forces dedicated server mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use -dedicated flag instead of -headless for T7x launch — the server
was not entering dedicated mode, causing it to loop in MP lobby state
instead of loading the configured map. Also adds ALTER_MOD env var for
Steam Workshop mod loading and a T7x example to the compose file.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Working implementations (BlackOps3ServerInstaller) only use -headless
without -dedicated. The -dedicated flag may be causing T7x to hang.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Create /tmp/.X11-unix before wineboot (was created after, so Xvfb
couldn't create sockets during build)
- Kill Xvfb after wineboot to prevent stale state in image layer
- Clean stale /tmp/.X99-lock at runtime before starting Xvfb
- Remove WINEDEBUG=-all so errors are visible for debugging
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Run wineboot -u during Docker build to pre-initialize Wine prefix
- Set WINEDLLOVERRIDES to skip Gecko/Mono install prompts (hangs in containers)
- Set WINEDEBUG=-all to suppress noisy debug output
- Set DISPLAY=:99 globally via ENV
- Add sleep after Xvfb start to avoid race condition
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add rcon-cli for interactive and one-shot RCON access via docker exec
- Extract shared game config logic (port defaults, config paths, game
detection, RCON password extraction) into game-config.sh
- Move all scripts into scripts/ directory for cleaner project structure
- Fix inverted healthcheck disable logic
- Fix unreachable code after exec in entrypoint.sh
- Fix bare except handler in pyquake3.py and remove debug print
- Pin Dockerfile base image to debian:trixie
- Change STOPSIGNAL from SIGKILL to SIGTERM for graceful shutdown
- Standardize error message prefixes to [ERROR] across scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This change introduces two new environment variables, `PLUTO_EXTRA_ARGS` and `IW4X_EXTRA_ARGS`, which allow users to provide additional command-line arguments to the Plutonium and IW4x game servers, respectively.
This provides greater flexibility for per-user customization without needing to modify the base Docker image.