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:
parent
1968684c03
commit
d18b397330
2 changed files with 137 additions and 1 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue