fix(host): create ~/.steam/sdk32 and sdk64 symlinks during install

L4D2 dedicated server expects to dlopen steamclient.so via
~/.steam/sdk32 (and sdk64). Without those symlinks, srcds_run logs
'cannot open shared object file' and SteamAPI_Init fails, which means
the server is invisible to the public Steam master server, Workshop
addon downloads break, and Steam 'Join Game' / lobby joins do not
reach the server (only direct-IP connect works).

SteamInstaller.install_or_update now ensures the symlinks exist after
SteamCMD finishes. Targets prefer SteamCMD's linux32/linux64 sibling
dirs; falls back to <install_dir>/bin/ if the siblings cannot be
located. Idempotent: re-running the install repairs or leaves the
symlinks alone.

Path.home() respects HOME, which the systemd web unit sets to
/var/lib/left4me, so the symlinks land in the left4me user's home.

Existing deploys can apply the fix by re-running 'Install' from /admin
without a full redeploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-07 02:11:27 +02:00
parent 1968684c03
commit d18b397330
No known key found for this signature in database
2 changed files with 137 additions and 1 deletions

View file

@ -1,3 +1,4 @@
import shutil
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@ -6,6 +7,57 @@ from l4d2host.process import run_command
from l4d2host.logging import emit_step from l4d2host.logging import emit_step
def _resolve_steamcmd_siblings(steamcmd: str) -> tuple[Path, Path] | None:
resolved = shutil.which(steamcmd)
base = Path(resolved).resolve() if resolved else Path(steamcmd)
if base.is_symlink():
base = base.resolve()
parent = base.parent
linux32 = parent / "linux32"
linux64 = parent / "linux64"
if linux32.is_dir() and linux64.is_dir():
return linux32, linux64
return None
def _set_symlink(link: Path, target: Path, on_stderr: Callable[[str], None] | None) -> None:
if link.is_symlink():
try:
current = link.resolve()
except OSError:
current = None
if current == target.resolve():
return
link.unlink()
elif link.exists():
if on_stderr is not None:
on_stderr(f"refusing to replace non-symlink at {link}")
return
link.symlink_to(target)
def _ensure_steam_sdk_symlinks(
install_dir: Path,
steamcmd: str,
*,
on_stdout: Callable[[str], None] | None,
on_stderr: Callable[[str], None] | None,
passthrough: bool,
) -> None:
emit_step("ensuring steam sdk symlinks...", on_stdout, passthrough)
siblings = _resolve_steamcmd_siblings(steamcmd)
if siblings is not None:
sdk32_target, sdk64_target = siblings
else:
fallback = install_dir / "bin"
sdk32_target, sdk64_target = fallback, fallback
steam_dir = Path.home() / ".steam"
steam_dir.mkdir(parents=True, exist_ok=True)
_set_symlink(steam_dir / "sdk32", sdk32_target, on_stderr)
_set_symlink(steam_dir / "sdk64", sdk64_target, on_stderr)
class SteamInstaller: class SteamInstaller:
def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"): def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"):
self.install_dir = get_left4me_root() / "installation" if install_dir is None else Path(install_dir) self.install_dir = get_left4me_root() / "installation" if install_dir is None else Path(install_dir)
@ -40,4 +92,11 @@ class SteamInstaller:
passthrough=passthrough, passthrough=passthrough,
should_cancel=should_cancel, should_cancel=should_cancel,
) )
_ensure_steam_sdk_symlinks(
self.install_dir,
self.steamcmd,
on_stdout=on_stdout,
on_stderr=on_stderr,
passthrough=passthrough,
)
emit_step("installation complete.", on_stdout, passthrough) emit_step("installation complete.", on_stdout, passthrough)

View file

@ -46,8 +46,8 @@ def test_default_install_dir_uses_left4me_root(tmp_path, monkeypatch):
def test_steam_installer_emits_steps(tmp_path, monkeypatch) -> None: def test_steam_installer_emits_steps(tmp_path, monkeypatch) -> None:
from pathlib import Path
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None) monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None)
steps: list[str] = [] steps: list[str] = []
@ -58,5 +58,82 @@ def test_steam_installer_emits_steps(tmp_path, monkeypatch) -> None:
assert steps == [ assert steps == [
"Step: downloading windows platform payload...", "Step: downloading windows platform payload...",
"Step: downloading linux platform payload...", "Step: downloading linux platform payload...",
"Step: ensuring steam sdk symlinks...",
"Step: installation complete." "Step: installation complete."
] ]
def test_install_creates_steam_sdk_symlinks_to_steamcmd_siblings(tmp_path, monkeypatch) -> None:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
steamcmd_dir = tmp_path / "opt" / "steamcmd"
(steamcmd_dir / "linux32").mkdir(parents=True)
(steamcmd_dir / "linux64").mkdir(parents=True)
fake_steamcmd = steamcmd_dir / "steamcmd.sh"
fake_steamcmd.write_text("#!/bin/sh\nexit 0\n")
fake_steamcmd.chmod(0o755)
monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None)
from l4d2host.steam_install import SteamInstaller
SteamInstaller(steamcmd=str(fake_steamcmd)).install_or_update()
sdk32 = home / ".steam" / "sdk32"
sdk64 = home / ".steam" / "sdk64"
assert sdk32.is_symlink()
assert sdk64.is_symlink()
assert sdk32.resolve() == (steamcmd_dir / "linux32").resolve()
assert sdk64.resolve() == (steamcmd_dir / "linux64").resolve()
def test_install_creates_steam_sdk_symlinks_falls_back_to_install_bin(tmp_path, monkeypatch) -> None:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
install_bin = tmp_path / "installation" / "bin"
install_bin.mkdir(parents=True)
isolated_dir = tmp_path / "no-siblings"
isolated_dir.mkdir()
fake_steamcmd = isolated_dir / "steamcmd.sh"
fake_steamcmd.write_text("#!/bin/sh\nexit 0\n")
fake_steamcmd.chmod(0o755)
monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None)
from l4d2host.steam_install import SteamInstaller
SteamInstaller(steamcmd=str(fake_steamcmd)).install_or_update()
sdk32 = home / ".steam" / "sdk32"
sdk64 = home / ".steam" / "sdk64"
assert sdk32.resolve() == install_bin.resolve()
assert sdk64.resolve() == install_bin.resolve()
def test_install_steam_sdk_symlinks_is_idempotent(tmp_path, monkeypatch) -> None:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
steamcmd_dir = tmp_path / "opt" / "steamcmd"
(steamcmd_dir / "linux32").mkdir(parents=True)
(steamcmd_dir / "linux64").mkdir(parents=True)
fake_steamcmd = steamcmd_dir / "steamcmd.sh"
fake_steamcmd.write_text("#!/bin/sh\nexit 0\n")
fake_steamcmd.chmod(0o755)
monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None)
from l4d2host.steam_install import SteamInstaller
installer = SteamInstaller(steamcmd=str(fake_steamcmd))
installer.install_or_update()
installer.install_or_update()
sdk32 = home / ".steam" / "sdk32"
assert sdk32.resolve() == (steamcmd_dir / "linux32").resolve()