Panel/modules/steam_workshop/lib/WorkshopInstaller.php
copilot-swe-agent[bot] fd860963d1
fix: address code review feedback
- Fix toggle/load_order handlers to use page-reload (not JSON) responses
- Remove dead jsonResponse helper method from WorkshopModController
- Fix robocopy exit code detection using ROBOCOPY_EXIT: sentinel (not text parsing)
- Fix rsync dry-run change detection using RSYNC_EXIT: sentinel
- Remove agentIdFromRemote() stub; pass agentId directly to triggerSteamCmdDownload() logging
- Fix 'enabled' checkbox default in profile_form to use ($profile['enabled'] ?? 1)
- Add missing error_toggle_failed / error_order_failed lang strings

Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/dbeebd0e-e7a5-469d-8a8c-e63193d1ebb0

Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
2026-04-30 18:06:05 +00:00

501 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
/*
* OGP / GSP Steam Workshop
* WorkshopInstaller: handles mod download (via agent SteamCMD) and
* copy/sync from agent cache to server install path.
*
* Template variables supported in all paths/scripts:
* {home_id} numeric home id
* {agent_id} numeric remote_server_id
* {workshop_app_id} Steam app id (e.g. 221100)
* {mod_id} Workshop mod id (numeric string)
* {mod_title} mod title (sanitised)
* {steamcmd_path} path to steamcmd.sh / steamcmd.exe on the agent
* {server_path} game server home_path
* {install_path} resolved install path for this mod
* {cache_path} resolved cache path for this mod
*/
require_once __DIR__ . '/WorkshopRepository.php';
class WorkshopInstaller
{
private WorkshopRepository $repo;
private string $logDir;
public function __construct(WorkshopRepository $repo)
{
$this->repo = $repo;
$this->logDir = __DIR__ . '/../logs';
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0775, true);
}
}
// ------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------
/**
* Install a workshop mod for a game server.
*
* @param array $home Row from getGameHome/getUserGameHome
* @param array $profile Row from gsp_workshop_game_profiles
* @param string $workshopId Numeric workshop item id
* @param string $steamCmdPath Path to steamcmd binary on the agent
* @return array{success:bool, message:string, restart_required:bool, log:list<string>}
*/
public function install(
array $home,
array $profile,
string $workshopId,
string $steamCmdPath = ''
): array {
$log = [];
// Validate workshop id
$workshopId = preg_replace('/[^0-9]/', '', $workshopId) ?? '';
if ($workshopId === '') {
return $this->fail('Workshop ID must be numeric.', $log);
}
$homeId = (int)($home['home_id'] ?? 0);
$agentId = (int)($home['remote_server_id'] ?? 0);
$appId = (string)($profile['workshop_app_id'] ?? '');
$osType = $this->detectOsType($home);
if ($homeId <= 0 || $agentId <= 0 || $appId === '') {
return $this->fail('Invalid home, agent, or app ID.', $log);
}
// Build template vars
$vars = $this->buildTemplateVars($home, $profile, $workshopId, '', $steamCmdPath);
$cachePath = $this->resolveTemplate((string)($profile['cache_path_template'] ?? ''), $vars);
$installPath = $this->resolveTemplate((string)($profile['install_path_template'] ?? ''), $vars);
$vars['{cache_path}'] = $cachePath;
$vars['{install_path}'] = $installPath;
// Build remote library
$remote = $this->buildRemote($home);
if ($remote === null) {
return $this->fail('Unable to connect to agent.', $log);
}
// Check agent connectivity
if ($remote->status_chk() !== 1) {
return $this->fail('Agent is offline.', $log);
}
// Check cache
$cacheEntry = $this->repo->getCacheEntry($agentId, $appId, $workshopId);
$log[] = "Cache check: agent={$agentId} app={$appId} mod={$workshopId}";
if ($cacheEntry === null || ($cacheEntry['status'] ?? '') !== 'cached') {
$log[] = 'Cache MISS triggering SteamCMD download on agent.';
$downloadResult = $this->triggerSteamCmdDownload(
$remote, $agentId, $appId, $workshopId, $steamCmdPath, $cachePath, $log
);
if (!$downloadResult) {
// Update cache status to 'missing' so the cron can retry
$this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'missing');
return $this->fail(
'SteamCMD download failed. The mod will be retried on the next scheduled update.',
$log
);
}
$log[] = 'SteamCMD download success.';
$this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'cached');
} else {
$log[] = 'Cache HIT using existing cached copy.';
}
// Copy / sync from cache to server install path
$syncResult = $this->syncToServer($remote, $profile, $vars, $log);
if (!$syncResult) {
return $this->fail('Sync from cache to server failed. Check agent logs.', $log);
}
// Optional install script (admin-defined only)
$installScript = trim((string)($profile['install_script'] ?? ''));
if ($installScript !== '') {
$this->runInstallScript($remote, $installScript, $vars, $log);
}
// Record in database
$this->repo->insertOrUpdateMod(
$homeId, $agentId, (int)$profile['id'], $appId, $workshopId,
$installPath, '', 0
);
$restartRequired = !empty($profile['requires_restart']);
$log[] = $restartRequired ? 'Restart required after mod install.' : 'Hot-reload capable (no restart required).';
return [
'success' => true,
'message' => 'Mod installed successfully.',
'restart_required' => $restartRequired,
'log' => $log,
];
}
/**
* Sync a single installed mod's cache into the server path.
* Called from pre-start and from the user "Sync now" button.
*
* @param array $home Game home row
* @param array $modRow Row from gsp_server_workshop_mods
* @param array $profile Row from gsp_workshop_game_profiles
* @return array{success:bool, changed:bool, message:string, log:list<string>}
*/
public function syncMod(array $home, array $modRow, array $profile): array
{
$log = [];
$workshopId = (string)($modRow['workshop_id'] ?? '');
$agentId = (int)($modRow['agent_id'] ?? 0);
$appId = (string)($modRow['workshop_app_id'] ?? '');
$cacheEntry = $this->repo->getCacheEntry($agentId, $appId, $workshopId);
if ($cacheEntry === null || ($cacheEntry['status'] ?? '') !== 'cached') {
$log[] = "Cache entry not available for mod {$workshopId} skipping sync.";
return ['success' => false, 'changed' => false, 'message' => 'Mod not cached yet.', 'log' => $log];
}
$remote = $this->buildRemote($home);
if ($remote === null || $remote->status_chk() !== 1) {
return ['success' => false, 'changed' => false, 'message' => 'Agent offline.', 'log' => $log];
}
$vars = $this->buildTemplateVars($home, $profile, $workshopId, $modRow['title'] ?? '');
$vars['{cache_path}'] = $this->resolveTemplate((string)($profile['cache_path_template'] ?? ''), $vars);
$vars['{install_path}'] = (string)($modRow['install_path'] ?? $this->resolveTemplate((string)($profile['install_path_template'] ?? ''), $vars));
$changed = $this->checkNeedsSync($remote, $vars['{cache_path}'], $vars['{install_path}'], $profile, $log);
if (!$changed) {
$log[] = 'No changes detected skipping sync.';
return ['success' => true, 'changed' => false, 'message' => 'Already up to date.', 'log' => $log];
}
$log[] = 'Changes detected syncing.';
$ok = $this->syncToServer($remote, $profile, $vars, $log);
return [
'success' => $ok,
'changed' => true,
'message' => $ok ? 'Sync complete.' : 'Sync failed.',
'log' => $log,
];
}
// ------------------------------------------------------------------
// Template resolution
// ------------------------------------------------------------------
/**
* Replace template placeholders in a string.
*
* @param array<string,string> $vars
*/
public function resolveTemplate(string $template, array $vars): string
{
return str_replace(array_keys($vars), array_values($vars), $template);
}
/**
* Build the standard template variable map for a home + profile + mod.
*
* @return array<string,string>
*/
public function buildTemplateVars(
array $home,
array $profile,
string $workshopId,
string $modTitle = '',
string $steamCmdPath = ''
): array {
$serverPath = rtrim((string)($home['home_path'] ?? ''), '/');
$safeName = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $modTitle) ?? '';
$folderNameTpl = (string)($profile['folder_name_template'] ?? '@{mod_id}');
$folderNameVars = [
'{mod_id}' => $workshopId,
'{mod_title}' => $safeName,
];
$folderName = str_replace(array_keys($folderNameVars), array_values($folderNameVars), $folderNameTpl);
return [
'{home_id}' => (string)($home['home_id'] ?? ''),
'{agent_id}' => (string)($home['remote_server_id'] ?? ''),
'{workshop_app_id}' => (string)($profile['workshop_app_id'] ?? ''),
'{mod_id}' => $workshopId,
'{mod_title}' => $safeName,
'{mod_folder}' => $folderName,
'{steamcmd_path}' => $steamCmdPath !== '' ? $steamCmdPath : '/home/gameserver/steamcmd',
'{server_path}' => $serverPath,
'{install_path}' => '', // filled by caller after resolution
'{cache_path}' => '', // filled by caller after resolution
];
}
// ------------------------------------------------------------------
// Private helpers
// ------------------------------------------------------------------
/** Build an OGPRemoteLibrary instance from a home row. */
private function buildRemote(array $home): ?object
{
if (!class_exists('OGPRemoteLibrary')) {
@require_once __DIR__ . '/../../../includes/lib_remote.php';
}
if (!class_exists('OGPRemoteLibrary')) {
return null;
}
$ip = (string)($home['agent_ip'] ?? '');
$port = (string)($home['agent_port'] ?? '');
$key = (string)($home['encryption_key'] ?? '');
$timeout = isset($home['timeout']) ? (int)$home['timeout'] : 30;
if ($ip === '' || $port === '') {
return null;
}
return new OGPRemoteLibrary($ip, $port, $key, $timeout);
}
/**
* Trigger a SteamCMD workshop_download_item on the agent via exec().
* Returns true on success.
*
* @param list<string> $log
*/
private function triggerSteamCmdDownload(
object $remote,
int $agentId,
string $appId,
string $workshopId,
string $steamCmdPath,
string $cachePath,
array &$log
): bool {
if ($steamCmdPath === '') {
$steamCmdPath = '/home/gameserver/steamcmd/steamcmd.sh';
}
$cmd = implode(' ', [
escapeshellarg($steamCmdPath),
'+login', 'anonymous',
'+workshop_download_item', escapeshellarg($appId), escapeshellarg($workshopId),
'validate',
'+quit',
]);
$log[] = "SteamCMD start: {$cmd}";
$this->writeLog("STEAMCMD START agent={$agentId} app={$appId} mod={$workshopId}");
$output = $remote->exec($cmd);
if ($output === null) {
$log[] = 'SteamCMD: no response from agent (command may still be running).';
$this->writeLog("STEAMCMD NO_RESPONSE agent={$agentId} app={$appId} mod={$workshopId}");
// Treat as unknown check file existence
} else {
$log[] = 'SteamCMD output: ' . substr((string)$output, 0, 500);
}
// Verify the download succeeded by checking for the cache path on the agent
$exists = $remote->rfile_exists($cachePath);
if ($exists === 1) {
$this->writeLog("STEAMCMD SUCCESS agent={$agentId} app={$appId} mod={$workshopId} path={$cachePath}");
return true;
}
$this->writeLog("STEAMCMD FAILURE agent={$agentId} app={$appId} mod={$workshopId} path={$cachePath}");
return false;
}
/**
* Check if cache path differs from install path using a dry-run compare.
* Returns true if sync is needed.
*
* @param list<string> $log
*/
private function checkNeedsSync(
object $remote,
string $cachePath,
string $installPath,
array $profile,
array &$log
): bool {
$copyMethod = (string)($profile['copy_method'] ?? 'rsync');
$log[] = "Pre-start compare: cache={$cachePath} dest={$installPath} method={$copyMethod}";
if ($copyMethod === 'rsync') {
// Dry-run: any output lines (beyond the exit sentinel) mean changes exist
$cmd = sprintf(
'rsync -rcn --delete %s %s 2>/dev/null; echo "RSYNC_EXIT:$?"',
escapeshellarg(rtrim($cachePath, '/') . '/'),
escapeshellarg(rtrim($installPath, '/') . '/')
);
$out = (string)$remote->exec($cmd);
// Strip the exit line, then check for any non-whitespace output
$body = preg_replace('/RSYNC_EXIT:\d+\s*$/', '', $out) ?? '';
return preg_match('/\S/', $body) === 1;
}
if ($copyMethod === 'robocopy') {
// List-only mode: robocopy exit code 0 = no differences, 1+ = changes or errors.
// Embed the exit code in output so we can read it back via exec().
$cmd = sprintf(
'robocopy /L /MIR /NJH /NJS %s %s; echo "ROBOCOPY_EXIT:$LASTEXITCODE"',
escapeshellarg($cachePath),
escapeshellarg($installPath)
);
$out = (string)$remote->exec($cmd);
if (preg_match('/ROBOCOPY_EXIT:(\d+)/', $out, $m)) {
// 0 = no change; 17 = informational (changes found); 8+ = error
return (int)$m[1] !== 0;
}
// If we cannot determine, assume sync is needed
return true;
}
// custom_script: always sync
return true;
}
/**
* Perform the actual copy/sync from cache to install path on the agent.
*
* @param array<string,string> $vars
* @param list<string> $log
*/
private function syncToServer(
object $remote,
array $profile,
array $vars,
array &$log
): bool {
$copyMethod = (string)($profile['copy_method'] ?? 'rsync');
$cachePath = $vars['{cache_path}'] ?? '';
$installPath = $vars['{install_path}'] ?? '';
if ($cachePath === '' || $installPath === '') {
$log[] = 'Sync skipped: empty cache or install path.';
return false;
}
$log[] = "Sync start: method={$copyMethod} cache={$cachePath} dest={$installPath}";
$this->writeLog("COPY START method={$copyMethod} cache={$cachePath} dest={$installPath}");
if ($copyMethod === 'rsync') {
$cmd = sprintf(
'mkdir -p %s && rsync -a --delete %s %s 2>&1; echo "EXIT:$?"',
escapeshellarg($installPath),
escapeshellarg(rtrim($cachePath, '/') . '/'),
escapeshellarg(rtrim($installPath, '/') . '/')
);
} elseif ($copyMethod === 'robocopy') {
$cmd = sprintf(
'robocopy /MIR /NJH /NJS %s %s; echo "ROBOCOPY_EXIT:$LASTEXITCODE"',
escapeshellarg($cachePath),
escapeshellarg($installPath)
);
} elseif ($copyMethod === 'custom_script') {
$script = trim((string)($profile['install_script'] ?? ''));
if ($script === '') {
$log[] = 'custom_script requested but install_script is empty falling back to rsync.';
$cmd = sprintf(
'mkdir -p %s && rsync -a --delete %s %s 2>&1; echo "EXIT:$?"',
escapeshellarg($installPath),
escapeshellarg(rtrim($cachePath, '/') . '/'),
escapeshellarg(rtrim($installPath, '/') . '/')
);
} else {
// The admin-defined script is templated; execute it via the agent exec()
$resolvedScript = $this->resolveTemplate($script, $vars);
$cmd = $resolvedScript . ' 2>&1; echo "EXIT:$?"';
}
} else {
$log[] = "Unknown copy method '{$copyMethod}'.";
return false;
}
$out = (string)$remote->exec($cmd);
$log[] = 'Sync output: ' . substr($out, 0, 500);
// Determine success from embedded exit code sentinel
if ($copyMethod === 'robocopy') {
if (preg_match('/ROBOCOPY_EXIT:(\d+)/', $out, $m)) {
// 07 = success/informational; 8+ = error
$ok = (int)$m[1] < 8;
} else {
$ok = true; // assume success if no code extracted
}
} else {
if (preg_match('/EXIT:(\d+)/', $out, $m)) {
$ok = (int)$m[1] === 0;
} else {
$ok = true;
}
}
if ($ok) {
$log[] = 'Sync success.';
$this->writeLog("COPY SUCCESS cache={$cachePath} dest={$installPath}");
} else {
$log[] = 'Sync failed (non-zero exit).';
$this->writeLog("COPY FAILURE cache={$cachePath} dest={$installPath}");
}
return $ok;
}
/**
* Run the admin-defined install script on the agent.
*
* @param array<string,string> $vars
* @param list<string> $log
*/
private function runInstallScript(
object $remote,
string $script,
array $vars,
array &$log
): void {
$resolved = $this->resolveTemplate($script, $vars);
$log[] = 'Running install script.';
$out = (string)$remote->exec($resolved . ' 2>&1');
$log[] = 'Script output: ' . substr($out, 0, 500);
$this->writeLog('SCRIPT OUTPUT: ' . substr($out, 0, 1000));
}
private function detectOsType(array $home): string
{
$gameKey = strtolower((string)($home['game_key'] ?? ''));
if (preg_match('/win/', $gameKey)) {
return 'windows';
}
return 'linux';
}
private function writeLog(string $message): void
{
$file = $this->logDir . '/workshop_install.log';
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
@file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
private function fail(string $message, array $log): array
{
$this->writeLog('FAIL: ' . $message);
return [
'success' => false,
'message' => $message,
'restart_required' => false,
'log' => $log,
];
}
}