From c84066dcd129f3eed9b566318f7e9865f218759d Mon Sep 17 00:00:00 2001 From: Amos Date: Thu, 30 Jul 2026 13:38:10 +0100 Subject: [PATCH] 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. --- CLAUDE.md | 2 +- scripts/game-config.sh | 27 ++++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 068bfec..6ae7c9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ...`: existence-guarded symlink helper; replaces unsafe `ln -sf src/{a,b,c} dest/` bash brace expansion. - - `link_dir_contents `: 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 `: 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 `: 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-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. diff --git a/scripts/game-config.sh b/scripts/game-config.sh index 146f85e..ac38fd1 100644 --- a/scripts/game-config.sh +++ b/scripts/game-config.sh @@ -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.