. */ declare(strict_types=1); namespace App\Services\System; use Psr\Log\LoggerInterface; use Shivas\VersioningBundle\Service\VersionManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\Process; /** * Handles the execution of Part-DB updates with safety mechanisms. * * This service should primarily be used from CLI commands, not web requests, * due to the long-running nature of updates and permission requirements. * * For web requests, use startBackgroundUpdate() method. */ class UpdateExecutor { private const LOCK_FILE = 'var/update.lock'; private const MAINTENANCE_FILE = 'var/maintenance.flag'; private const UPDATE_LOG_DIR = 'var/log/updates'; private const PROGRESS_FILE = 'var/update_progress.json'; /** @var array */ private array $steps = []; private ?string $currentLogFile = null; public function __construct( #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir, private readonly LoggerInterface $logger, private readonly Filesystem $filesystem, private readonly InstallationTypeDetector $installationTypeDetector, private readonly UpdateChecker $updateChecker, private readonly BackupManager $backupManager, private readonly CommandRunHelper $commandRunHelper, #[Autowire(param: 'app.debug_mode')] private readonly bool $debugMode = false, ) { } /** * Get the current version string for use in filenames. */ private function getCurrentVersionString(): string { return $this->updateChecker->getCurrentVersionString(); } /** * Check if an update is currently in progress. */ public function isLocked(): bool { // Check if lock is stale (older than 1 hour) $lockData = $this->getLockInfo(); if ($lockData === null) { return false; } if ($lockData && isset($lockData['started_at'])) { $startedAt = new \DateTime($lockData['started_at']); $now = new \DateTime(); $diff = $now->getTimestamp() - $startedAt->getTimestamp(); // If lock is older than 1 hour, consider it stale if ($diff > 3600) { $this->logger->warning('Found stale update lock, removing it'); $this->releaseLock(); return false; } } return true; } /** * Get lock information, or null if not locked. * @return null|array{started_at: string, pid: int, user: string} */ public function getLockInfo(): ?array { $lockFile = $this->project_dir . '/' . self::LOCK_FILE; if (!file_exists($lockFile)) { return null; } return json_decode(file_get_contents($lockFile), true, 512, JSON_THROW_ON_ERROR); } /** * Check if maintenance mode is enabled. */ public function isMaintenanceMode(): bool { return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE); } /** * Get maintenance mode information. * @return null|array{enabled_at: string, reason: string} */ public function getMaintenanceInfo(): ?array { $maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE; if (!file_exists($maintenanceFile)) { return null; } return json_decode(file_get_contents($maintenanceFile), true, 512, JSON_THROW_ON_ERROR); } /** * Acquire an exclusive lock for the update process. */ public function acquireLock(): bool { if ($this->isLocked()) { return false; } $lockFile = $this->project_dir . '/' . self::LOCK_FILE; $lockDir = dirname($lockFile); if (!is_dir($lockDir)) { $this->filesystem->mkdir($lockDir); } $lockData = [ 'started_at' => (new \DateTime())->format('c'), 'pid' => getmypid(), 'user' => get_current_user(), ]; $this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); return true; } /** * Release the update lock. */ public function releaseLock(): void { $lockFile = $this->project_dir . '/' . self::LOCK_FILE; if (file_exists($lockFile)) { $this->filesystem->remove($lockFile); } } /** * Enable maintenance mode to block user access during update. */ public function enableMaintenanceMode(string $reason = 'Update in progress'): void { $maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE; $maintenanceDir = dirname($maintenanceFile); if (!is_dir($maintenanceDir)) { $this->filesystem->mkdir($maintenanceDir); } $data = [ 'enabled_at' => (new \DateTime())->format('c'), 'reason' => $reason, ]; $this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); } /** * Disable maintenance mode. */ public function disableMaintenanceMode(): void { $maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE; if (file_exists($maintenanceFile)) { $this->filesystem->remove($maintenanceFile); } } /** * Reset PHP OPcache for the web server process. * * OPcache in PHP-FPM is separate from CLI. After updating code files, * PHP-FPM may still serve stale cached bytecode, causing constructor * mismatches and 500 errors. This method creates a temporary PHP script * in the public directory, invokes it via HTTP to reset OPcache in the * web server context, then removes the script. * * @return bool Whether OPcache was successfully reset */ private function resetOpcache(): bool { $token = bin2hex(random_bytes(16)); $resetScript = $this->project_dir . '/public/_opcache_reset_' . $token . '.php'; try { // Create a temporary PHP script that resets OPcache $scriptContent = 'filesystem->dumpFile($resetScript, $scriptContent); // Try to invoke it via HTTP on localhost $urls = [ 'http://127.0.0.1/_opcache_reset_' . $token . '.php', 'http://localhost/_opcache_reset_' . $token . '.php', ]; $success = false; foreach ($urls as $url) { try { $context = stream_context_create([ 'http' => [ 'timeout' => 5, 'ignore_errors' => true, ], ]); $response = @file_get_contents($url, false, $context); if ($response === 'OK') { $this->logger->info('OPcache reset via ' . $url); $success = true; break; } } catch (\Throwable $e) { // Try next URL continue; } } if (!$success) { $this->logger->info('OPcache reset via HTTP not available, trying CLI fallback'); // CLI opcache_reset() only affects CLI, but try anyway if (function_exists('opcache_reset')) { opcache_reset(); } } return $success; } catch (\Throwable $e) { $this->logger->warning('OPcache reset failed: ' . $e->getMessage()); return false; } finally { // Ensure the temp script is removed if (file_exists($resetScript)) { @unlink($resetScript); } } } /** * Validate that we can perform an update. * * @return array{valid: bool, errors: array} */ public function validateUpdatePreconditions(): array { $errors = []; // Check installation type $installType = $this->installationTypeDetector->detect(); if (!$installType->supportsAutoUpdate()) { $errors[] = sprintf( 'Installation type "%s" does not support automatic updates. %s', $installType->getLabel(), $installType->getUpdateInstructions() ); } // Check for Git installation if ($installType === InstallationType::GIT) { // Check if git is available $process = new Process(['git', '--version']); $process->run(); if (!$process->isSuccessful()) { $errors[] = 'Git command not found. Please ensure Git is installed and in PATH.'; } // Check for local changes $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); $process->run(); if (!empty(trim($process->getOutput()))) { $errors[] = 'There are uncommitted local changes. Please commit or stash them before updating.'; } } // Check if composer is available $process = new Process(['composer', '--version']); $process->run(); if (!$process->isSuccessful()) { $errors[] = 'Composer command not found. Please ensure Composer is installed and in PATH.'; } // Check if PHP CLI is available $process = new Process(['php', '--version']); $process->run(); if (!$process->isSuccessful()) { $errors[] = 'PHP CLI not found. Please ensure PHP is installed and in PATH.'; } // Check if yarn is available (for frontend assets) $process = new Process(['yarn', '--version']); $process->run(); if (!$process->isSuccessful()) { $errors[] = 'Yarn command not found. Please ensure Yarn is installed and in PATH for frontend asset compilation.'; } // Check write permissions $testDirs = ['var', 'vendor', 'public']; foreach ($testDirs as $dir) { $fullPath = $this->project_dir . '/' . $dir; if (is_dir($fullPath) && !is_writable($fullPath)) { $errors[] = sprintf('Directory "%s" is not writable.', $dir); } } // Check if already locked if ($this->isLocked()) { $lockInfo = $this->getLockInfo(); $errors[] = sprintf( 'An update is already in progress (started at %s).', $lockInfo['started_at'] ?? 'unknown time' ); } return [ 'valid' => empty($errors), 'errors' => $errors, ]; } /** * Execute the update to a specific version. * * @param string $targetVersion The target version/tag to update to (e.g., "v2.6.0") * @param bool $createBackup Whether to create a backup before updating * @param callable|null $onProgress Callback for progress updates * * @return array{success: bool, steps: array, rollback_tag: ?string, error: ?string, log_file: ?string} */ public function executeUpdate( string $targetVersion, bool $createBackup = true, ?callable $onProgress = null ): array { $this->steps = []; $rollbackTag = null; $startTime = microtime(true); // Initialize log file $this->initializeLogFile($targetVersion); $log = function (string $step, string $message, bool $success = true, ?float $duration = null) use ($onProgress): void { $entry = [ 'step' => $step, 'message' => $message, 'success' => $success, 'timestamp' => (new \DateTime())->format('c'), 'duration' => $duration, ]; $this->steps[] = $entry; $this->writeToLogFile($entry); $this->logger->info("Update [{$step}]: {$message}", ['success' => $success]); if ($onProgress) { $onProgress($entry); } }; try { // Validate preconditions $validation = $this->validateUpdatePreconditions(); if (!$validation['valid']) { throw new \RuntimeException('Precondition check failed: ' . implode('; ', $validation['errors'])); } // Step 1: Acquire lock $stepStart = microtime(true); if (!$this->acquireLock()) { throw new \RuntimeException('Could not acquire update lock. Another update may be in progress.'); } $log('lock', 'Acquired exclusive update lock', true, microtime(true) - $stepStart); // Step 2: Enable maintenance mode $stepStart = microtime(true); $this->enableMaintenanceMode('Updating to ' . $targetVersion); $log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart); // Step 3: Create rollback point with version info $stepStart = microtime(true); $currentVersion = $this->getCurrentVersionString(); $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); $rollbackTag = 'pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His'); $this->runCommand(['git', 'tag', $rollbackTag], 'Create rollback tag'); $log('rollback_tag', 'Created rollback tag: ' . $rollbackTag, true, microtime(true) - $stepStart); // Step 4: Create backup (optional) if ($createBackup) { $stepStart = microtime(true); $backupFile = $this->backupManager->createBackup($targetVersion); $log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart); } // Step 5: Fetch from remote $stepStart = microtime(true); $this->runCommand(['git', 'fetch', '--tags', '--force', 'origin'], 'Fetch from origin', 120); $log('fetch', 'Fetched latest changes and tags from origin', true, microtime(true) - $stepStart); // Step 6: Checkout target version $stepStart = microtime(true); $this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version'); $log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart); // Step 7: Install PHP dependencies $stepStart = microtime(true); if ($this->debugMode) { $this->runCommand([ // Install with dev dependencies in debug mode 'composer', 'install', '--no-interaction', '--no-progress', ], 'Install PHP dependencies', 600); } else { $this->runCommand([ 'composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction', '--no-progress', ], 'Install PHP dependencies', 600); } $log('composer', 'Installed/updated PHP dependencies', true, microtime(true) - $stepStart); // Step 8: Install frontend dependencies $stepStart = microtime(true); $this->runCommand([ 'yarn', 'install', '--frozen-lockfile', '--non-interactive', ], 'Install frontend dependencies', 600); $log('yarn_install', 'Installed frontend dependencies', true, microtime(true) - $stepStart); // Step 9: Build frontend assets $stepStart = microtime(true); $this->runCommand([ 'yarn', 'build', ], 'Build frontend assets', 600); $log('yarn_build', 'Built frontend assets', true, microtime(true) - $stepStart); // Step 10: Run database migrations $stepStart = microtime(true); $this->runCommand([ 'php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction', '--allow-no-migration', ], 'Run migrations', 300); $log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart); // Step 11: Clear cache $stepStart = microtime(true); $this->runCommand([ 'php', 'bin/console', 'cache:pool:clear', '--all', '--env=prod', '--no-interaction', ], 'Clear cache', 120); $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); // Step 12: Warm up cache $stepStart = microtime(true); $this->runCommand([ 'php', 'bin/console', 'cache:warmup', '--env=prod', ], 'Warmup cache', 120); $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); // Step 13: Reset OPcache (if available) $stepStart = microtime(true); $opcacheResult = $this->resetOpcache(); $log('opcache_reset', $opcacheResult ? 'Reset PHP OPcache for web server' : 'OPcache reset skipped (not available or not needed)', true, microtime(true) - $stepStart); // Step 14: Disable maintenance mode $stepStart = microtime(true); $this->disableMaintenanceMode(); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); // Step 15: Release lock $stepStart = microtime(true); $this->releaseLock(); $totalDuration = microtime(true) - $startTime; $log('complete', sprintf('Update completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart); return [ 'success' => true, 'steps' => $this->steps, 'rollback_tag' => $rollbackTag, 'error' => null, 'log_file' => $this->currentLogFile, 'duration' => $totalDuration, ]; } catch (\Exception $e) { $log('error', 'Update failed: ' . $e->getMessage(), false); // Attempt rollback if ($rollbackTag) { try { $this->runCommand(['git', 'checkout', $rollbackTag], 'Rollback'); $log('rollback', 'Rolled back to: ' . $rollbackTag, true); // Re-run composer install after rollback $this->runCommand([ 'composer', 'install', '--no-dev', '--optimize-autoloader', '--no-interaction', ], 'Reinstall dependencies after rollback', 600); $log('rollback_composer', 'Reinstalled PHP dependencies after rollback', true); // Re-run yarn install after rollback $this->runCommand([ 'yarn', 'install', '--frozen-lockfile', '--non-interactive', ], 'Reinstall frontend dependencies after rollback', 600); $log('rollback_yarn_install', 'Reinstalled frontend dependencies after rollback', true); // Re-run yarn build after rollback $this->runCommand([ 'yarn', 'build', ], 'Rebuild frontend assets after rollback', 600); $log('rollback_yarn_build', 'Rebuilt frontend assets after rollback', true); // Clear cache after rollback $this->runCommand([ 'php', 'bin/console', 'cache:pool:clear', '--all', '--env=prod', ], 'Clear cache after rollback', 120); $log('rollback_cache', 'Cleared cache after rollback', true); // Reset OPcache after rollback $this->resetOpcache(); } catch (\Exception $rollbackError) { $log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false); } } // Clean up $this->disableMaintenanceMode(); $this->releaseLock(); return [ 'success' => false, 'steps' => $this->steps, 'rollback_tag' => $rollbackTag, 'error' => $e->getMessage(), 'log_file' => $this->currentLogFile, 'duration' => microtime(true) - $startTime, ]; } } /** * Run a shell command with proper error handling. */ private function runCommand(array $command, string $description, int $timeout = 120): string { return $this->commandRunHelper->runCommand($command, $description, $timeout); } /** * Initialize the log file for this update. */ private function initializeLogFile(string $targetVersion): void { $logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR; if (!is_dir($logDir)) { $this->filesystem->mkdir($logDir, 0755); } // Include version numbers in log filename: update-v2.5.1-to-v2.6.0-2024-01-30-185400.log $currentVersion = $this->getCurrentVersionString(); $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); $this->currentLogFile = $logDir . '/update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.log'; $header = sprintf( "Part-DB Update Log\n" . "==================\n" . "Started: %s\n" . "From Version: %s\n" . "Target Version: %s\n" . "==================\n\n", date('Y-m-d H:i:s'), $currentVersion, $targetVersion ); file_put_contents($this->currentLogFile, $header); } /** * Write an entry to the log file. */ private function writeToLogFile(array $entry): void { if (!$this->currentLogFile) { return; } $line = sprintf( "[%s] %s: %s%s\n", $entry['timestamp'], strtoupper($entry['step']), $entry['message'], $entry['duration'] ? sprintf(' (%.2fs)', $entry['duration']) : '' ); file_put_contents($this->currentLogFile, $line, FILE_APPEND); } /** * Get list of update log files. * @return array{file: string, path: string, date: int, size: int}[] */ public function getUpdateLogs(): array { $logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR; if (!is_dir($logDir)) { return []; } $logs = []; foreach (glob($logDir . '/update-*.log') as $logFile) { $logs[] = [ 'file' => basename($logFile), 'path' => $logFile, 'date' => filemtime($logFile), 'size' => filesize($logFile), ]; } // Sort by date descending usort($logs, static fn($a, $b) => $b['date'] <=> $a['date']); return $logs; } /** * Delete a specific update log file. */ public function deleteLog(string $filename): bool { // Validate filename pattern for security if (!preg_match('/^update-[\w.\-]+\.log$/', $filename)) { $this->logger->warning('Attempted to delete invalid log filename: ' . $filename); return false; } $logPath = $this->project_dir . '/' . self::UPDATE_LOG_DIR . '/' . basename($filename); if (!file_exists($logPath)) { return false; } try { $this->filesystem->remove($logPath); $this->logger->info('Deleted update log: ' . $filename); return true; } catch (\Exception $e) { $this->logger->error('Failed to delete update log: ' . $e->getMessage()); return false; } } /** * Restore from a backup file with maintenance mode and cache clearing. * * This wraps BackupManager::restoreBackup with additional safety measures * like lock acquisition, maintenance mode, and cache operations. * * @param string $filename The backup filename to restore * @param bool $restoreDatabase Whether to restore the database * @param bool $restoreConfig Whether to restore config files * @param bool $restoreAttachments Whether to restore attachments * @param callable|null $onProgress Callback for progress updates * @return array{success: bool, steps: array, error: ?string} */ public function restoreBackup( string $filename, bool $restoreDatabase = true, bool $restoreConfig = false, bool $restoreAttachments = false, ?callable $onProgress = null ): array { $this->steps = []; $startTime = microtime(true); $log = function (string $step, string $message, bool $success, ?float $duration = null) use ($onProgress): void { $entry = [ 'step' => $step, 'message' => $message, 'success' => $success, 'timestamp' => (new \DateTime())->format('c'), 'duration' => $duration, ]; $this->steps[] = $entry; $this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]); if ($onProgress) { $onProgress($entry); } }; try { $stepStart = microtime(true); // Step 1: Acquire lock if (!$this->acquireLock()) { throw new \RuntimeException('Could not acquire lock. Another operation may be in progress.'); } $log('lock', 'Acquired exclusive restore lock', true, microtime(true) - $stepStart); // Step 2: Enable maintenance mode $stepStart = microtime(true); $this->enableMaintenanceMode('Restoring from backup...'); $log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart); // Step 3: Delegate to BackupManager for core restoration $stepStart = microtime(true); $result = $this->backupManager->restoreBackup( $filename, $restoreDatabase, $restoreConfig, $restoreAttachments, function ($entry) use ($log) { // Forward progress from BackupManager $log($entry['step'], $entry['message'], $entry['success'], $entry['duration'] ?? null); } ); if (!$result['success']) { throw new \RuntimeException($result['error'] ?? 'Restore failed'); } // Step 4: Clear cache $stepStart = microtime(true); $this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache'); $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); // Step 5: Warm up cache $stepStart = microtime(true); $this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache'); $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); // Step 6: Reset OPcache $stepStart = microtime(true); $this->resetOpcache(); $log('opcache_reset', 'Reset PHP OPcache', true, microtime(true) - $stepStart); // Step 7: Disable maintenance mode $stepStart = microtime(true); $this->disableMaintenanceMode(); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); // Step 8: Release lock $this->releaseLock(); $totalDuration = microtime(true) - $startTime; $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true); return [ 'success' => true, 'steps' => $this->steps, 'error' => null, ]; } catch (\Throwable $e) { $this->logger->error('Restore failed: ' . $e->getMessage(), [ 'exception' => $e, 'file' => $filename, ]); // Try to clean up try { $this->disableMaintenanceMode(); $this->releaseLock(); } catch (\Throwable $cleanupError) { $this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]); } return [ 'success' => false, 'steps' => $this->steps, 'error' => $e->getMessage(), ]; } } /** * Get the path to the progress file. */ public function getProgressFilePath(): string { return $this->project_dir . '/' . self::PROGRESS_FILE; } /** * Save progress to file for web UI polling. * @param array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string} $progress */ private function saveProgress(array $progress): void { $progressFile = $this->getProgressFilePath(); $progressDir = dirname($progressFile); if (!is_dir($progressDir)) { $this->filesystem->mkdir($progressDir); } $this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); } /** * Get current update progress from file. * @return null|array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string} */ public function getProgress(): ?array { $progressFile = $this->getProgressFilePath(); if (!file_exists($progressFile)) { return null; } $data = json_decode(file_get_contents($progressFile), true, 512, JSON_THROW_ON_ERROR); // If the progress file is stale (older than 30 minutes), consider it invalid if ($data && isset($data['started_at'])) { $startedAt = strtotime($data['started_at']); if (time() - $startedAt > 1800) { $this->clearProgress(); return null; } } return $data; } /** * Clear progress file. */ public function clearProgress(): void { $progressFile = $this->getProgressFilePath(); if (file_exists($progressFile)) { $this->filesystem->remove($progressFile); } } /** * Check if an update is currently running (based on progress file). */ public function isUpdateRunning(): bool { $progress = $this->getProgress(); if (!$progress) { return false; } return isset($progress['status']) && $progress['status'] === 'running'; } /** * Start the update process in the background. * Returns the process ID or null on failure. */ public function startBackgroundUpdate(string $targetVersion, bool $createBackup = true): ?int { // Validate first $validation = $this->validateUpdatePreconditions(); if (!$validation['valid']) { $this->logger->error('Update validation failed', ['errors' => $validation['errors']]); return null; } // Initialize progress file $this->saveProgress([ 'status' => 'starting', 'target_version' => $targetVersion, 'create_backup' => $createBackup, 'started_at' => (new \DateTime())->format('c'), 'current_step' => 0, 'total_steps' => 15, 'step_name' => 'initializing', 'step_message' => 'Starting update process...', 'steps' => [], 'error' => null, ]); // Build the command to run in background // Use 'php' from PATH as PHP_BINARY might point to php-fpm $consolePath = $this->project_dir . '/bin/console'; $logFile = $this->project_dir . '/var/log/update-background.log'; // Ensure log directory exists $logDir = dirname($logFile); if (!is_dir($logDir)) { $this->filesystem->mkdir($logDir, 0755); } //If we are on Windows, we cannot use nohup if (PHP_OS_FAMILY === 'Windows') { $command = sprintf( 'start /B php %s partdb:update %s %s --force --no-interaction >> %s 2>&1', escapeshellarg($consolePath), escapeshellarg($targetVersion), $createBackup ? '' : '--no-backup', escapeshellarg($logFile) ); } else { //Unix like platforms should be able to use nohup // Use nohup to properly detach the process from the web request // The process will continue running even after the PHP request ends $command = sprintf( 'nohup php %s partdb:update %s %s --force --no-interaction >> %s 2>&1 &', escapeshellarg($consolePath), escapeshellarg($targetVersion), $createBackup ? '' : '--no-backup', escapeshellarg($logFile) ); } $this->logger->info('Starting background update', [ 'command' => $command, 'target_version' => $targetVersion, ]); // Execute in background using shell_exec for proper detachment // shell_exec with & runs the command in background //@php-ignore-next-line We really need to use shell_exec here $output = shell_exec($command); // Give it a moment to start usleep(500000); // 500ms // Check if progress file was updated (indicates process started) $progress = $this->getProgress(); if ($progress && isset($progress['status'])) { $this->logger->info('Background update started successfully'); return 1; // Return a non-null value to indicate success } $this->logger->error('Background update may not have started', ['output' => $output]); return 1; // Still return success as the process might just be slow to start } /** * Execute update with progress file updates for web UI. * This is called by the CLI command and updates the progress file. */ public function executeUpdateWithProgress( string $targetVersion, bool $createBackup = true, ?callable $onProgress = null ): array { $totalSteps = 13; $currentStep = 0; $updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void { $currentStep++; $progress = $this->getProgress() ?? [ 'status' => 'running', 'target_version' => $targetVersion, 'create_backup' => $createBackup, 'started_at' => (new \DateTime())->format('c'), 'steps' => [], ]; $progress['current_step'] = $currentStep; $progress['total_steps'] = $totalSteps; $progress['step_name'] = $stepName; $progress['step_message'] = $message; $progress['status'] = 'running'; $progress['steps'][] = [ 'step' => $stepName, 'message' => $message, 'success' => $success, 'timestamp' => (new \DateTime())->format('c'), ]; $this->saveProgress($progress); }; // Wrap the existing executeUpdate with progress tracking $result = $this->executeUpdate($targetVersion, $createBackup, function ($entry) use ($updateProgress, $onProgress) { $updateProgress($entry['step'], $entry['message'], $entry['success']); if ($onProgress) { $onProgress($entry); } }); // Update final status $finalProgress = $this->getProgress() ?? []; $finalProgress['status'] = $result['success'] ? 'completed' : 'failed'; $finalProgress['completed_at'] = (new \DateTime())->format('c'); $finalProgress['result'] = $result; $finalProgress['error'] = $result['error']; $this->saveProgress($finalProgress); return $result; } }