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.
This commit is contained in:
Amos 2026-07-30 13:38:10 +01:00
parent e82b1e2565
commit c84066dcd1
No known key found for this signature in database
2 changed files with 21 additions and 8 deletions

View file

@ -61,7 +61,7 @@ Everything runs as the `plutainer` user from `/home/plutainer/.plutainer`. All e
- `resolve_config_layout`: sets `CONFIG_SOT_DIR` and `ALT_CONFIG_DIR` based on `PLUTAINER_USE_RAW_CONFIGS`. Default: SOT = `configs/`, ALT = engine dir. With raw mode on: swapped.
- `resolve_config_path`: convenience wrapper that resolves the engine dir + layout in one call so healthcheck/rcon-cli only need this.
- `link_files <src> <dest> <name1>...`: existence-guarded symlink helper; replaces unsafe `ln -sf src/{a,b,c} dest/` bash brace expansion.
- `link_dir_contents <src_root> <dest_root> <name>`: creates `dest_root/name` as a **real** dir and symlinks the *contents* of `src_root/name` into it. Use instead of `link_files` for any dir that must be writable while also carrying read-only host game files — the engine config dir (`link_configs` fans cfg symlinks into it) or a dir an updater writes into. Symlinking the dir itself would make the path read-only, and `ln -sf src/name dest/` would nest the link as `dest/name/name`. Glob-guarded (no bogus `*` symlink), and replaces a directory symlink left by an older image.
- `link_dir_contents <src_root> <dest_root> <name>`: mirrors `src_root/name` into `dest_root/name`, recreating **every** directory level as a real dir and symlinking only leaf files. Use instead of `link_files` for any dir that must be writable while also carrying read-only host game files — the engine config dir (`link_configs` fans cfg symlinks into it) or a dir an updater writes into. Symlinking a dir at *any* depth inherits the read-only mount: linking just the top level still left `zone/patch/` read-only and killed the iw4x-launcher with `failed to extract file: zone/patch/iw4_credits_load.ff`. Never overwrites a real file already at the destination, so an updater-written copy wins over the host's. Replaces a directory symlink left by an older image.
- `seed_configs <game-key> <asset-root> <cfg-root-rel>`: walks bundled seed, lifts top-level `*.cfg` files inside `cfg-root-rel` into `CONFIG_SOT_DIR`, places everything else under `asset-root`. Idempotent.
- `link_configs <engine-dir1> [engine-dir2 ...]`: variadic. Fans out symlinks from every `configs/*.cfg` into each engine dir using relative paths. Refuses to overwrite a real (non-symlink) file at engine path (warns instead). Reaps dangling cfg symlinks. No-op when `PLUTAINER_USE_RAW_CONFIGS=true`.
- `ensure_config_present`: checks that `CONFIG_FILE` exists at `CONFIG_SOT_DIR`. If absent there but present as a real file at the ALT location, moves it (auto-lift). If absent everywhere, prints a refusal with a `find -iname` case-insensitive hint, returns non-zero.

View file

@ -209,16 +209,29 @@ link_dir_contents() {
mkdir -p "$dest"
if [[ ! -d "$src_root/$name" ]]; then
echo "[WARN] missing $src_root/$name — nothing to link into $dest" >&2
local src="$src_root/$name"
if [[ ! -d "$src" ]]; then
echo "[WARN] missing $src — nothing to link into $dest" >&2
return 0
fi
# Guard the glob: bash leaves an unmatched `*` literal, so an empty source
# dir would otherwise create a bogus symlink named `*`.
if compgen -G "$src_root/$name/*" > /dev/null; then
ln -sfn "$src_root/$name"/* "$dest"/
fi
# Mirror the source's directory skeleton as REAL dirs, then symlink only leaf
# entries. Symlinking a subdirectory would inherit the read-only mount and
# block writes one level down — e.g. iw4x-launcher extracting into
# zone/patch/ dies with "failed to extract file" when zone/patch is a link.
local rel
while IFS= read -r -d '' rel; do
mkdir -p "$dest/$rel"
done < <(cd "$src" && find . -mindepth 1 -type d -printf '%P\0')
# Never clobber a real file already at the destination: an updater may have
# written a newer copy there, and that must win over the host's version.
while IFS= read -r -d '' rel; do
if [[ -e "$dest/$rel" && ! -L "$dest/$rel" ]]; then
continue
fi
ln -sfn "$src/$rel" "$dest/$rel"
done < <(cd "$src" && find . \( -type f -o -type l \) -printf '%P\0')
}
# Copy bundled community seed configs into the volume on first run.