Commit graph

31 commits

Author SHA1 Message Date
Amos
f9a7953652
SteamCMD family: generalise 7DTD support, add CS2/L4D2/HL2:DM, unify the CoD side (#9)
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>
2026-08-16 17:12:31 +01:00
Amos
d117dede7d
feat: CoD4x support, headless T7x, unauthenticated healthcheck, log rotation
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.
2026-08-15 17:22:14 +01:00
Amos
bbe6e69b6c
feat(arm64): degrade to iw4x-unsupported instead of failing the whole image
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.
2026-07-30 14:56:39 +01:00
Amos
a5b6029ed4
feat(iw4x): bundle a config seed so first run is a working vanilla server
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.
2026-07-30 14:13:34 +01:00
Amos
9fc39b9e4e
docs(iw4x): correct why zone/ is split — write mechanism, not ownership
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.
2026-07-30 13:51:24 +01:00
Amos
25f53f5b04
fix(iw4x): cede zone/patch and zone/zonebuilder to the launcher
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.
2026-07-30 13:47:15 +01:00
Amos
b673b31f78
fix(iw4x): replace symlinked subdirs when mirroring, not just the top level
`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.
2026-07-30 13:39:34 +01:00
Amos
c84066dcd1
fix(iw4x): mirror host dirs recursively so zone/patch stays writable
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.
2026-07-30 13:38:10 +01:00
Amos
e82b1e2565
fix(iw4x): adapt to rewritten launcher, persist install in volume
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.
2026-07-30 13:14:10 +01:00
Amos
4ce33c637e
V2 layout (#4)
* 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>
2026-05-25 11:56:13 +01:00
Amos
67175ab5b3
Guard t7x zone glob to avoid literal '*' symlink 2026-05-12 19:04:54 +01:00
Amos
4c04f3a0a2
Bundle community config seeds for first-run scaffolding
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.
2026-05-05 14:46:17 +01:00
Amos
1d63922564
Fix HEALTHCHECK env var logic so true means enabled
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.
2026-05-05 13:47:52 +01:00
Amos
221fca3d68
log watcher - repair between restarts 2026-04-18 11:55:29 +01:00
Amos
c91b6aa285
Whoops, need relative logging. 2026-04-18 11:27:59 +01:00
Amos
e749a787a4
Log Watcher 2026-04-18 11:04:26 +01:00
Amos
bd03dcf042
Remove bundled lobby script, document t7x/ folder requirement instead
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>
2026-03-31 16:18:31 +01:00
Amos
ed21763947
Re-enable IW4x support and add T7x lobby mode script
- 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>
2026-03-31 15:36:06 +01:00
Amos
02a0ae85da
Fix T7x server mode by only symlinking the dedicated server binary
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>
2026-03-30 11:26:44 +01:00
Amos
2046fe2d36
Fix T7x dedicated server mode and add mod support
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>
2026-03-30 11:11:39 +01:00
Amos
882ec4f4f2
Remove -dedicated flag from T7x launch, use -headless only
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>
2026-03-30 10:38:26 +01:00
Amos
485c21aad1
Fix Xvfb setup ordering and stale lock cleanup
- 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>
2026-03-29 22:47:32 +01:00
Amos
c0440ba25f
Initialize Wine prefix at build time and prevent Gecko/Mono hang
- 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>
2026-03-29 22:25:27 +01:00
Amos
bbcb09b47f
Use manual Xvfb instead of xvfb-run to preserve Wine output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:01:48 +01:00
Amos
01db25a031
Add xvfb virtual framebuffer for T7x Wine display requirement
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 21:08:27 +01:00
Amos
740421cb35
Symlink BlackOps3 executables required by T7x
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 21:00:29 +01:00
Amos
4b7a9a7dbb
Add -headless flag to T7x launch args for Wine compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 20:50:40 +01:00
Amos
2b27b72c81
Add Alterware/T7x (Black Ops III) dedicated server support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 20:18:31 +01:00
Amos
558560615d
Fix rcon-cli symlink resolution for SCRIPT_DIR and pyquake3 import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 18:18:24 +01:00
Amos
55fa5480e9
Temporarily disable IW4x support due to upstream launcher issue
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 17:54:55 +01:00
Amos
443fff7eb7
Add rcon-cli, deduplicate shared logic, and reorganize scripts
- 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>
2026-03-29 17:44:08 +01:00