From 42fe781ef884b627a498a57f633130fb10ac9dfd Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:36:33 +0100 Subject: [PATCH 01/43] Add Update Manager for automated Part-DB updates This feature adds a comprehensive Update Manager similar to Mainsail's update system, allowing administrators to update Part-DB directly from the web interface. Features: - Web UI at /admin/update-manager showing current and available versions - Support for Git-based installations with automatic update execution - Maintenance mode during updates to prevent user access - Automatic database backup before updates - Git rollback points for recovery (tags created before each update) - Progress tracking with real-time status updates - Update history and log viewing - Downgrade support with appropriate UI messaging - CLI command `php bin/console partdb:update` for server-side updates New files: - UpdateManagerController: Handles all web UI routes - UpdateCommand: CLI command for running updates - UpdateExecutor: Core update execution logic with safety mechanisms - UpdateChecker: GitHub API integration for version checking - InstallationTypeDetector: Detects installation type (Git/Docker/ZIP) - MaintenanceModeSubscriber: Blocks user access during maintenance - UpdateExtension: Twig functions for update notifications UI improvements: - Update notification in navbar for admins when update available - Confirmation dialogs for update/downgrade actions - Downgrade-specific text throughout the interface - Progress page with auto-refresh --- config/permissions.yaml | 4 + src/Command/UpdateCommand.php | 446 ++++++++++ src/Controller/UpdateManagerController.php | 268 ++++++ .../MaintenanceModeSubscriber.php | 231 +++++ .../System/InstallationTypeDetector.php | 224 +++++ src/Services/System/UpdateChecker.php | 349 ++++++++ src/Services/System/UpdateExecutor.php | 832 ++++++++++++++++++ src/Services/Trees/ToolsTreeBuilder.php | 7 + src/Twig/UpdateExtension.php | 79 ++ templates/_navbar.html.twig | 13 + .../admin/update_manager/index.html.twig | 374 ++++++++ .../admin/update_manager/log_viewer.html.twig | 40 + .../admin/update_manager/progress.html.twig | 196 +++++ .../update_manager/release_notes.html.twig | 110 +++ templates/maintenance/maintenance.html.twig | 251 ++++++ translations/messages.en.xlf | 702 +++++++++++++++ 16 files changed, 4126 insertions(+) create mode 100644 src/Command/UpdateCommand.php create mode 100644 src/Controller/UpdateManagerController.php create mode 100644 src/EventSubscriber/MaintenanceModeSubscriber.php create mode 100644 src/Services/System/InstallationTypeDetector.php create mode 100644 src/Services/System/UpdateChecker.php create mode 100644 src/Services/System/UpdateExecutor.php create mode 100644 src/Twig/UpdateExtension.php create mode 100644 templates/admin/update_manager/index.html.twig create mode 100644 templates/admin/update_manager/log_viewer.html.twig create mode 100644 templates/admin/update_manager/progress.html.twig create mode 100644 templates/admin/update_manager/release_notes.html.twig create mode 100644 templates/maintenance/maintenance.html.twig diff --git a/config/permissions.yaml b/config/permissions.yaml index 8c6a145e..0dabf9d3 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -297,6 +297,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co show_updates: label: "perm.system.show_available_updates" apiTokenRole: ROLE_API_ADMIN + manage_updates: + label: "perm.system.manage_updates" + alsoSet: ['show_updates', 'server_infos'] + apiTokenRole: ROLE_API_ADMIN attachments: diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php new file mode 100644 index 00000000..4f2cae86 --- /dev/null +++ b/src/Command/UpdateCommand.php @@ -0,0 +1,446 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Command; + +use App\Services\System\InstallationType; +use App\Services\System\UpdateChecker; +use App\Services\System\UpdateExecutor; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])] +class UpdateCommand extends Command +{ + public function __construct(private readonly UpdateChecker $updateChecker, + private readonly UpdateExecutor $updateExecutor) + { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setHelp(<<<'HELP' +The %command.name% command checks for Part-DB updates and can install them. + +Check for updates: + php %command.full_name% --check + +List available versions: + php %command.full_name% --list + +Update to the latest version: + php %command.full_name% + +Update to a specific version: + php %command.full_name% v2.6.0 + +Update without creating a backup (faster but riskier): + php %command.full_name% --no-backup + +Non-interactive update for scripts: + php %command.full_name% --force + +View update logs: + php %command.full_name% --logs +HELP + ) + ->addArgument( + 'version', + InputArgument::OPTIONAL, + 'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.' + ) + ->addOption( + 'check', + 'c', + InputOption::VALUE_NONE, + 'Only check for updates without installing' + ) + ->addOption( + 'list', + 'l', + InputOption::VALUE_NONE, + 'List all available versions' + ) + ->addOption( + 'no-backup', + null, + InputOption::VALUE_NONE, + 'Skip creating a backup before updating (not recommended)' + ) + ->addOption( + 'force', + 'f', + InputOption::VALUE_NONE, + 'Skip confirmation prompts' + ) + ->addOption( + 'include-prerelease', + null, + InputOption::VALUE_NONE, + 'Include pre-release versions' + ) + ->addOption( + 'logs', + null, + InputOption::VALUE_NONE, + 'Show recent update logs' + ) + ->addOption( + 'refresh', + 'r', + InputOption::VALUE_NONE, + 'Force refresh of cached version information' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + // Handle --logs option + if ($input->getOption('logs')) { + return $this->showLogs($io); + } + + // Handle --refresh option + if ($input->getOption('refresh')) { + $io->text('Refreshing version information...'); + $this->updateChecker->refreshGitInfo(); + $io->success('Version cache cleared.'); + } + + // Handle --list option + if ($input->getOption('list')) { + return $this->listVersions($io, $input->getOption('include-prerelease')); + } + + // Get update status + $status = $this->updateChecker->getUpdateStatus(); + + // Display current status + $io->title('Part-DB Update Manager'); + + $this->displayStatus($io, $status); + + // Handle --check option + if ($input->getOption('check')) { + return $this->checkOnly($io, $status); + } + + // Validate we can update + $validationResult = $this->validateUpdate($io, $status); + if ($validationResult !== null) { + return $validationResult; + } + + // Determine target version + $targetVersion = $input->getArgument('version'); + $includePrerelease = $input->getOption('include-prerelease'); + + if (!$targetVersion) { + $latest = $this->updateChecker->getLatestRelease($includePrerelease); + if (!$latest) { + $io->error('Could not determine the latest version. Please specify a version manually.'); + return Command::FAILURE; + } + $targetVersion = $latest['tag']; + } + + // Validate target version + if (!$this->updateChecker->isNewerVersion($targetVersion)) { + $io->warning(sprintf( + 'Version %s is not newer than the current version %s.', + $targetVersion, + $status['current_version'] + )); + + if (!$input->getOption('force')) { + if (!$io->confirm('Do you want to proceed anyway?', false)) { + $io->info('Update cancelled.'); + return Command::SUCCESS; + } + } + } + + // Confirm update + if (!$input->getOption('force')) { + $io->section('Update Plan'); + + $io->listing([ + sprintf('Target version: %s', $targetVersion), + $input->getOption('no-backup') + ? 'Backup will be SKIPPED' + : 'A full backup will be created before updating', + 'Maintenance mode will be enabled during update', + 'Database migrations will be run automatically', + 'Cache will be cleared and rebuilt', + ]); + + $io->warning('The update process may take several minutes. Do not interrupt it.'); + + if (!$io->confirm('Do you want to proceed with the update?', false)) { + $io->info('Update cancelled.'); + return Command::SUCCESS; + } + } + + // Execute update + return $this->executeUpdate($io, $targetVersion, !$input->getOption('no-backup')); + } + + private function displayStatus(SymfonyStyle $io, array $status): void + { + $io->definitionList( + ['Current Version' => sprintf('%s', $status['current_version'])], + ['Latest Version' => $status['latest_version'] + ? sprintf('%s', $status['latest_version']) + : 'Unknown'], + ['Installation Type' => $status['installation']['type_name']], + ['Git Branch' => $status['git']['branch'] ?? 'N/A'], + ['Git Commit' => $status['git']['commit'] ?? 'N/A'], + ['Local Changes' => $status['git']['has_local_changes'] + ? 'Yes (update blocked)' + : 'No'], + ['Commits Behind' => $status['git']['commits_behind'] > 0 + ? sprintf('%d', $status['git']['commits_behind']) + : '0'], + ['Update Available' => $status['update_available'] + ? 'Yes' + : 'No'], + ['Can Auto-Update' => $status['can_auto_update'] + ? 'Yes' + : 'No'], + ); + + if (!empty($status['update_blockers'])) { + $io->warning('Update blockers: ' . implode(', ', $status['update_blockers'])); + } + } + + private function checkOnly(SymfonyStyle $io, array $status): int + { + if (!$status['check_enabled']) { + $io->warning('Update checking is disabled in privacy settings.'); + return Command::SUCCESS; + } + + if ($status['update_available']) { + $io->success(sprintf( + 'A new version is available: %s (current: %s)', + $status['latest_version'], + $status['current_version'] + )); + + if ($status['release_url']) { + $io->text(sprintf('Release notes: %s', $status['release_url'], $status['release_url'])); + } + + if ($status['can_auto_update']) { + $io->text(''); + $io->text('Run php bin/console partdb:update to update.'); + } else { + $io->text(''); + $io->text($status['installation']['update_instructions']); + } + + return Command::SUCCESS; + } + + $io->success('You are running the latest version.'); + return Command::SUCCESS; + } + + private function validateUpdate(SymfonyStyle $io, array $status): ?int + { + // Check if update checking is enabled + if (!$status['check_enabled']) { + $io->error('Update checking is disabled in privacy settings. Enable it to use automatic updates.'); + return Command::FAILURE; + } + + // Check installation type + if (!$status['can_auto_update']) { + $io->error('Automatic updates are not supported for this installation type.'); + $io->text($status['installation']['update_instructions']); + return Command::FAILURE; + } + + // Validate preconditions + $validation = $this->updateExecutor->validateUpdatePreconditions(); + if (!$validation['valid']) { + $io->error('Cannot proceed with update:'); + $io->listing($validation['errors']); + return Command::FAILURE; + } + + return null; + } + + private function executeUpdate(SymfonyStyle $io, string $targetVersion, bool $createBackup): int + { + $io->section('Executing Update'); + $io->text(sprintf('Updating to version: %s', $targetVersion)); + $io->text(''); + + $progressCallback = function (array $step) use ($io): void { + $icon = $step['success'] ? '✓' : '✗'; + $duration = $step['duration'] ? sprintf(' (%.1fs)', $step['duration']) : ''; + $io->text(sprintf(' %s %s: %s%s', $icon, $step['step'], $step['message'], $duration)); + }; + + // Use executeUpdateWithProgress to update the progress file for web UI + $result = $this->updateExecutor->executeUpdateWithProgress($targetVersion, $createBackup, $progressCallback); + + $io->text(''); + + if ($result['success']) { + $io->success(sprintf( + 'Successfully updated to %s in %.1f seconds!', + $targetVersion, + $result['duration'] + )); + + $io->text([ + sprintf('Rollback tag: %s', $result['rollback_tag']), + sprintf('Log file: %s', $result['log_file']), + ]); + + $io->note('If you encounter any issues, you can rollback using: git checkout ' . $result['rollback_tag']); + + return Command::SUCCESS; + } + + $io->error('Update failed: ' . $result['error']); + + if ($result['rollback_tag']) { + $io->warning(sprintf('System was rolled back to: %s', $result['rollback_tag'])); + } + + if ($result['log_file']) { + $io->text(sprintf('See log file for details: %s', $result['log_file'])); + } + + return Command::FAILURE; + } + + private function listVersions(SymfonyStyle $io, bool $includePrerelease): int + { + $releases = $this->updateChecker->getAvailableReleases(15); + $currentVersion = $this->updateChecker->getCurrentVersionString(); + + if (empty($releases)) { + $io->warning('Could not fetch available versions. Check your internet connection.'); + return Command::FAILURE; + } + + $io->title('Available Part-DB Versions'); + + $table = new Table($io); + $table->setHeaders(['Tag', 'Version', 'Released', 'Status']); + + foreach ($releases as $release) { + if (!$includePrerelease && $release['prerelease']) { + continue; + } + + $version = $release['version']; + $status = []; + + if (version_compare($version, $currentVersion, '=')) { + $status[] = 'current'; + } elseif (version_compare($version, $currentVersion, '>')) { + $status[] = 'newer'; + } + + if ($release['prerelease']) { + $status[] = 'pre-release'; + } + + $table->addRow([ + $release['tag'], + $version, + (new \DateTime($release['published_at']))->format('Y-m-d'), + implode(' ', $status) ?: '-', + ]); + } + + $table->render(); + + $io->text(''); + $io->text('Use php bin/console partdb:update [tag] to update to a specific version.'); + + return Command::SUCCESS; + } + + private function showLogs(SymfonyStyle $io): int + { + $logs = $this->updateExecutor->getUpdateLogs(); + + if (empty($logs)) { + $io->info('No update logs found.'); + return Command::SUCCESS; + } + + $io->title('Recent Update Logs'); + + $table = new Table($io); + $table->setHeaders(['Date', 'File', 'Size']); + + foreach (array_slice($logs, 0, 10) as $log) { + $table->addRow([ + date('Y-m-d H:i:s', $log['date']), + $log['file'], + $this->formatBytes($log['size']), + ]); + } + + $table->render(); + + $io->text(''); + $io->text('Log files are stored in: var/log/updates/'); + + return Command::SUCCESS; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $unitIndex = 0; + + while ($bytes >= 1024 && $unitIndex < count($units) - 1) { + $bytes /= 1024; + $unitIndex++; + } + + return sprintf('%.1f %s', $bytes, $units[$unitIndex]); + } +} diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php new file mode 100644 index 00000000..10a719de --- /dev/null +++ b/src/Controller/UpdateManagerController.php @@ -0,0 +1,268 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Controller; + +use App\Services\System\UpdateChecker; +use App\Services\System\UpdateExecutor; +use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +/** + * Controller for the Update Manager web interface. + * + * This provides a read-only view of update status and instructions. + * Actual updates should be performed via the CLI command for safety. + */ +#[Route('/admin/update-manager')] +class UpdateManagerController extends AbstractController +{ + public function __construct(private readonly UpdateChecker $updateChecker, + private readonly UpdateExecutor $updateExecutor, + private readonly VersionManagerInterface $versionManager) + { + + } + + /** + * Main update manager page. + */ + #[Route('', name: 'admin_update_manager', methods: ['GET'])] + public function index(): Response + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + $status = $this->updateChecker->getUpdateStatus(); + $availableUpdates = $this->updateChecker->getAvailableUpdates(); + $validation = $this->updateExecutor->validateUpdatePreconditions(); + + return $this->render('admin/update_manager/index.html.twig', [ + 'status' => $status, + 'available_updates' => $availableUpdates, + 'all_releases' => $this->updateChecker->getAvailableReleases(10), + 'validation' => $validation, + 'is_locked' => $this->updateExecutor->isLocked(), + 'lock_info' => $this->updateExecutor->getLockInfo(), + 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), + 'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(), + 'update_logs' => $this->updateExecutor->getUpdateLogs(), + 'backups' => $this->updateExecutor->getBackups(), + ]); + } + + /** + * AJAX endpoint to check update status. + */ + #[Route('/status', name: 'admin_update_manager_status', methods: ['GET'])] + public function status(): JsonResponse + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + return $this->json([ + 'status' => $this->updateChecker->getUpdateStatus(), + 'is_locked' => $this->updateExecutor->isLocked(), + 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), + 'lock_info' => $this->updateExecutor->getLockInfo(), + ]); + } + + /** + * AJAX endpoint to refresh version information. + */ + #[Route('/refresh', name: 'admin_update_manager_refresh', methods: ['POST'])] + public function refresh(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + // Validate CSRF token + if (!$this->isCsrfTokenValid('update_manager_refresh', $request->request->get('_token'))) { + return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_FORBIDDEN); + } + + $this->updateChecker->refreshGitInfo(); + + return $this->json([ + 'success' => true, + 'status' => $this->updateChecker->getUpdateStatus(), + ]); + } + + /** + * View release notes for a specific version. + */ + #[Route('/release/{tag}', name: 'admin_update_manager_release', methods: ['GET'])] + public function releaseNotes(string $tag): Response + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + $releases = $this->updateChecker->getAvailableReleases(20); + $release = null; + + foreach ($releases as $r) { + if ($r['tag'] === $tag) { + $release = $r; + break; + } + } + + if (!$release) { + throw $this->createNotFoundException('Release not found'); + } + + return $this->render('admin/update_manager/release_notes.html.twig', [ + 'release' => $release, + 'current_version' => $this->updateChecker->getCurrentVersionString(), + ]); + } + + /** + * View an update log file. + */ + #[Route('/log/{filename}', name: 'admin_update_manager_log', methods: ['GET'])] + public function viewLog(string $filename): Response + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + // Security: Only allow viewing files from the update logs directory + $logs = $this->updateExecutor->getUpdateLogs(); + $logPath = null; + + foreach ($logs as $log) { + if ($log['file'] === $filename) { + $logPath = $log['path']; + break; + } + } + + if (!$logPath || !file_exists($logPath)) { + throw $this->createNotFoundException('Log file not found'); + } + + $content = file_get_contents($logPath); + + return $this->render('admin/update_manager/log_viewer.html.twig', [ + 'filename' => $filename, + 'content' => $content, + ]); + } + + /** + * Start an update process. + */ + #[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])] + public function startUpdate(Request $request): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + // Validate CSRF token + if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Check if update is already running + if ($this->updateExecutor->isLocked() || $this->updateExecutor->isUpdateRunning()) { + $this->addFlash('error', 'An update is already in progress.'); + return $this->redirectToRoute('admin_update_manager'); + } + + $targetVersion = $request->request->get('version'); + $createBackup = $request->request->getBoolean('backup', true); + + if (!$targetVersion) { + // Get latest version if not specified + $latest = $this->updateChecker->getLatestRelease(); + if (!$latest) { + $this->addFlash('error', 'Could not determine target version.'); + return $this->redirectToRoute('admin_update_manager'); + } + $targetVersion = $latest['tag']; + } + + // Validate preconditions + $validation = $this->updateExecutor->validateUpdatePreconditions(); + if (!$validation['valid']) { + $this->addFlash('error', implode(' ', $validation['errors'])); + return $this->redirectToRoute('admin_update_manager'); + } + + // Start the background update + $pid = $this->updateExecutor->startBackgroundUpdate($targetVersion, $createBackup); + + if (!$pid) { + $this->addFlash('error', 'Failed to start update process.'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Redirect to progress page + return $this->redirectToRoute('admin_update_manager_progress'); + } + + /** + * Update progress page. + */ + #[Route('/progress', name: 'admin_update_manager_progress', methods: ['GET'])] + public function progress(): Response + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + $progress = $this->updateExecutor->getProgress(); + $currentVersion = $this->versionManager->getVersion()->toString(); + + // Determine if this is a downgrade + $isDowngrade = false; + if ($progress && isset($progress['target_version'])) { + $targetVersion = ltrim($progress['target_version'], 'v'); + $isDowngrade = version_compare($targetVersion, $currentVersion, '<'); + } + + return $this->render('admin/update_manager/progress.html.twig', [ + 'progress' => $progress, + 'is_locked' => $this->updateExecutor->isLocked(), + 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), + 'is_downgrade' => $isDowngrade, + 'current_version' => $currentVersion, + ]); + } + + /** + * AJAX endpoint to get update progress. + */ + #[Route('/progress/status', name: 'admin_update_manager_progress_status', methods: ['GET'])] + public function progressStatus(): JsonResponse + { + $this->denyAccessUnlessGranted('@system.show_updates'); + + $progress = $this->updateExecutor->getProgress(); + + return $this->json([ + 'progress' => $progress, + 'is_locked' => $this->updateExecutor->isLocked(), + 'is_maintenance' => $this->updateExecutor->isMaintenanceMode(), + ]); + } +} diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php new file mode 100644 index 00000000..60623b45 --- /dev/null +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -0,0 +1,231 @@ +. + */ + +declare(strict_types=1); + + +namespace App\EventSubscriber; + +use App\Services\System\UpdateExecutor; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Twig\Environment; + +/** + * Blocks all web requests when maintenance mode is enabled during updates. + */ +class MaintenanceModeSubscriber implements EventSubscriberInterface +{ + public function __construct(private readonly UpdateExecutor $updateExecutor, + private readonly Environment $twig) + { + + } + + public static function getSubscribedEvents(): array + { + return [ + // High priority to run before other listeners + KernelEvents::REQUEST => ['onKernelRequest', 512], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + // Only handle main requests + if (!$event->isMainRequest()) { + return; + } + + // Skip if not in maintenance mode + if (!$this->updateExecutor->isMaintenanceMode()) { + return; + } + + // Allow CLI requests + if (php_sapi_name() === 'cli') { + return; + } + + // Get maintenance info + $maintenanceInfo = $this->updateExecutor->getMaintenanceInfo(); + $lockInfo = $this->updateExecutor->getLockInfo(); + + // Calculate how long the update has been running + $duration = null; + if ($lockInfo && isset($lockInfo['started_at'])) { + try { + $startedAt = new \DateTime($lockInfo['started_at']); + $now = new \DateTime(); + $duration = $now->getTimestamp() - $startedAt->getTimestamp(); + } catch (\Exception) { + // Ignore date parsing errors + } + } + + // Try to render the Twig template, fall back to simple HTML + try { + $content = $this->twig->render('maintenance/maintenance.html.twig', [ + 'reason' => $maintenanceInfo['reason'] ?? 'Maintenance in progress', + 'started_at' => $maintenanceInfo['enabled_at'] ?? null, + 'duration' => $duration, + ]); + } catch (\Exception) { + // Fallback to simple HTML if Twig fails + $content = $this->getSimpleMaintenanceHtml($maintenanceInfo, $duration); + } + + $response = new Response($content, Response::HTTP_SERVICE_UNAVAILABLE); + $response->headers->set('Retry-After', '30'); + $response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate'); + + $event->setResponse($response); + } + + /** + * Generate a simple maintenance page HTML without Twig. + */ + private function getSimpleMaintenanceHtml(?array $maintenanceInfo, ?int $duration): string + { + $reason = htmlspecialchars($maintenanceInfo['reason'] ?? 'Update in progress'); + $durationText = $duration !== null ? sprintf('%d seconds', $duration) : 'a moment'; + + return << + + + + + + Part-DB - Maintenance + + + +
+
+ ⚙️ +
+

Part-DB is Updating

+

We're making things better. This should only take a moment.

+ +
+ {$reason} +
+ +
+
+
+ +

+ Update running for {$durationText}
+ This page will automatically refresh every 15 seconds. +

+
+ + +HTML; + } +} diff --git a/src/Services/System/InstallationTypeDetector.php b/src/Services/System/InstallationTypeDetector.php new file mode 100644 index 00000000..0cd99a04 --- /dev/null +++ b/src/Services/System/InstallationTypeDetector.php @@ -0,0 +1,224 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; + +/** + * Detects the installation type of Part-DB to determine the appropriate update strategy. + */ +enum InstallationType: string +{ + case GIT = 'git'; + case DOCKER = 'docker'; + case ZIP_RELEASE = 'zip_release'; + case UNKNOWN = 'unknown'; + + public function getLabel(): string + { + return match($this) { + self::GIT => 'Git Clone', + self::DOCKER => 'Docker', + self::ZIP_RELEASE => 'Release Archive', + self::UNKNOWN => 'Unknown', + }; + } + + public function supportsAutoUpdate(): bool + { + return match($this) { + self::GIT => true, + self::DOCKER => false, + self::ZIP_RELEASE => true, + self::UNKNOWN => false, + }; + } + + public function getUpdateInstructions(): string + { + return match($this) { + self::GIT => 'Run: php bin/console partdb:update', + self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d', + self::ZIP_RELEASE => 'Download the new release, extract it, and run migrations.', + self::UNKNOWN => 'Unable to determine installation type. Please update manually.', + }; + } +} + +class InstallationTypeDetector +{ + public function __construct(#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir) + { + + } + + /** + * Detect the installation type based on filesystem markers. + */ + public function detect(): InstallationType + { + // Check for Docker environment first + if ($this->isDocker()) { + return InstallationType::DOCKER; + } + + // Check for Git installation + if ($this->isGitInstall()) { + return InstallationType::GIT; + } + + // Check for ZIP release (has VERSION file but no .git) + if ($this->isZipRelease()) { + return InstallationType::ZIP_RELEASE; + } + + return InstallationType::UNKNOWN; + } + + /** + * Check if running inside a Docker container. + */ + public function isDocker(): bool + { + // Check for /.dockerenv file + if (file_exists('/.dockerenv')) { + return true; + } + + // Check for DOCKER environment variable + if (getenv('DOCKER') !== false) { + return true; + } + + // Check for container runtime in cgroup + if (file_exists('/proc/1/cgroup')) { + $cgroup = @file_get_contents('/proc/1/cgroup'); + if ($cgroup !== false && (str_contains($cgroup, 'docker') || str_contains($cgroup, 'containerd'))) { + return true; + } + } + + return false; + } + + /** + * Check if this is a Git-based installation. + */ + public function isGitInstall(): bool + { + return is_dir($this->project_dir . '/.git'); + } + + /** + * Check if this appears to be a ZIP release installation. + */ + public function isZipRelease(): bool + { + // Has VERSION file but no .git directory + return file_exists($this->project_dir . '/VERSION') && !$this->isGitInstall(); + } + + /** + * Get detailed information about the installation. + */ + public function getInstallationInfo(): array + { + $type = $this->detect(); + + $info = [ + 'type' => $type, + 'type_name' => $type->getLabel(), + 'supports_auto_update' => $type->supportsAutoUpdate(), + 'update_instructions' => $type->getUpdateInstructions(), + 'project_dir' => $this->project_dir, + ]; + + if ($type === InstallationType::GIT) { + $info['git'] = $this->getGitInfo(); + } + + if ($type === InstallationType::DOCKER) { + $info['docker'] = $this->getDockerInfo(); + } + + return $info; + } + + /** + * Get Git-specific information. + */ + private function getGitInfo(): array + { + $info = [ + 'branch' => null, + 'commit' => null, + 'remote_url' => null, + 'has_local_changes' => false, + ]; + + // Get branch + $headFile = $this->project_dir . '/.git/HEAD'; + if (file_exists($headFile)) { + $head = file_get_contents($headFile); + if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) { + $info['branch'] = trim($matches[1]); + } + } + + // Get remote URL + $configFile = $this->project_dir . '/.git/config'; + if (file_exists($configFile)) { + $config = file_get_contents($configFile); + if (preg_match('#url = (.+)#', $config, $matches)) { + $info['remote_url'] = trim($matches[1]); + } + } + + // Get commit hash + $process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir); + $process->run(); + if ($process->isSuccessful()) { + $info['commit'] = trim($process->getOutput()); + } + + // Check for local changes + $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); + $process->run(); + $info['has_local_changes'] = !empty(trim($process->getOutput())); + + return $info; + } + + /** + * Get Docker-specific information. + */ + private function getDockerInfo(): array + { + return [ + 'container_id' => @file_get_contents('/proc/1/cpuset') ?: null, + 'image' => getenv('DOCKER_IMAGE') ?: null, + ]; + } +} diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php new file mode 100644 index 00000000..a881f614 --- /dev/null +++ b/src/Services/System/UpdateChecker.php @@ -0,0 +1,349 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\System; + +use App\Settings\SystemSettings\PrivacySettings; +use Psr\Log\LoggerInterface; +use Shivas\VersioningBundle\Service\VersionManagerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Version\Version; + +/** + * Enhanced update checker that fetches release information including changelogs. + */ +class UpdateChecker +{ + private const GITHUB_API_BASE = 'https://api.github.com/repos/Part-DB/Part-DB-server'; + private const CACHE_KEY_RELEASES = 'update_checker_releases'; + private const CACHE_KEY_COMMITS = 'update_checker_commits_behind'; + private const CACHE_TTL = 60 * 60 * 6; // 6 hours + private const CACHE_TTL_ERROR = 60 * 60; // 1 hour on error + + public function __construct(private readonly HttpClientInterface $httpClient, + private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager, + private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger, + private readonly InstallationTypeDetector $installationTypeDetector, + #[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode, + #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir) + { + + } + + /** + * Get the current installed version. + */ + public function getCurrentVersion(): Version + { + return $this->versionManager->getVersion(); + } + + /** + * Get the current version as string. + */ + public function getCurrentVersionString(): string + { + return $this->getCurrentVersion()->toString(); + } + + /** + * Get Git repository information. + */ + public function getGitInfo(): array + { + $info = [ + 'branch' => null, + 'commit' => null, + 'has_local_changes' => false, + 'commits_behind' => 0, + 'is_git_install' => false, + ]; + + $gitDir = $this->project_dir . '/.git'; + + if (!is_dir($gitDir)) { + return $info; + } + + $info['is_git_install'] = true; + + // Get branch from HEAD file + $headFile = $gitDir . '/HEAD'; + if (file_exists($headFile)) { + $head = file_get_contents($headFile); + if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) { + $info['branch'] = trim($matches[1]); + } + } + + // Get current commit + $process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir); + $process->run(); + if ($process->isSuccessful()) { + $info['commit'] = trim($process->getOutput()); + } + + // Check for local changes + $process = new Process(['git', 'status', '--porcelain'], $this->project_dir); + $process->run(); + $info['has_local_changes'] = !empty(trim($process->getOutput())); + + // Get commits behind (fetch first) + if ($info['branch']) { + // Try to get cached commits behind count + $info['commits_behind'] = $this->getCommitsBehind($info['branch']); + } + + return $info; + } + + /** + * Get number of commits behind the remote branch (cached). + */ + private function getCommitsBehind(string $branch): int + { + if (!$this->privacySettings->checkForUpdates) { + return 0; + } + + $cacheKey = self::CACHE_KEY_COMMITS . '_' . md5($branch); + + return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) { + $item->expiresAfter(self::CACHE_TTL); + + // Fetch from remote first + $process = new Process(['git', 'fetch', '--tags', 'origin'], $this->project_dir); + $process->run(); + + // Count commits behind + $process = new Process(['git', 'rev-list', 'HEAD..origin/' . $branch, '--count'], $this->project_dir); + $process->run(); + + return $process->isSuccessful() ? (int) trim($process->getOutput()) : 0; + }); + } + + /** + * Force refresh git information by invalidating cache. + */ + public function refreshGitInfo(): void + { + $gitInfo = $this->getGitInfo(); + if ($gitInfo['branch']) { + $this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . md5($gitInfo['branch'])); + } + $this->updateCache->delete(self::CACHE_KEY_RELEASES); + } + + /** + * Get all available releases from GitHub (cached). + * + * @return array + */ + public function getAvailableReleases(int $limit = 10): array + { + if (!$this->privacySettings->checkForUpdates) { + return []; + } + + return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) { + $item->expiresAfter(self::CACHE_TTL); + + try { + $response = $this->httpClient->request('GET', self::GITHUB_API_BASE . '/releases', [ + 'query' => ['per_page' => $limit], + 'headers' => [ + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'Part-DB-Update-Checker', + ], + ]); + + $releases = []; + foreach ($response->toArray() as $release) { + // Extract assets (for ZIP download) + $assets = []; + foreach ($release['assets'] ?? [] as $asset) { + if (str_ends_with($asset['name'], '.zip') || str_ends_with($asset['name'], '.tar.gz')) { + $assets[] = [ + 'name' => $asset['name'], + 'url' => $asset['browser_download_url'], + 'size' => $asset['size'], + ]; + } + } + + $releases[] = [ + 'version' => ltrim($release['tag_name'], 'v'), + 'tag' => $release['tag_name'], + 'name' => $release['name'] ?? $release['tag_name'], + 'url' => $release['html_url'], + 'published_at' => $release['published_at'], + 'body' => $release['body'] ?? '', + 'prerelease' => $release['prerelease'] ?? false, + 'draft' => $release['draft'] ?? false, + 'assets' => $assets, + 'tarball_url' => $release['tarball_url'] ?? null, + 'zipball_url' => $release['zipball_url'] ?? null, + ]; + } + + return $releases; + } catch (\Exception $e) { + $this->logger->error('Failed to fetch releases from GitHub: ' . $e->getMessage()); + $item->expiresAfter(self::CACHE_TTL_ERROR); + + if ($this->is_dev_mode) { + throw $e; + } + + return []; + } + }); + } + + /** + * Get the latest stable release. + */ + public function getLatestRelease(bool $includePrerelease = false): ?array + { + $releases = $this->getAvailableReleases(); + + foreach ($releases as $release) { + // Skip drafts always + if ($release['draft']) { + continue; + } + + // Skip prereleases unless explicitly included + if (!$includePrerelease && $release['prerelease']) { + continue; + } + + return $release; + } + + return null; + } + + /** + * Check if a specific version is newer than current. + */ + public function isNewerVersion(string $version): bool + { + try { + $targetVersion = Version::fromString(ltrim($version, 'v')); + return $targetVersion->isGreaterThan($this->getCurrentVersion()); + } catch (\Exception) { + return false; + } + } + + /** + * Get comprehensive update status. + */ + public function getUpdateStatus(): array + { + $current = $this->getCurrentVersion(); + $latest = $this->getLatestRelease(); + $gitInfo = $this->getGitInfo(); + $installInfo = $this->installationTypeDetector->getInstallationInfo(); + + $updateAvailable = false; + $latestVersion = null; + $latestTag = null; + + if ($latest) { + try { + $latestVersionObj = Version::fromString($latest['version']); + $updateAvailable = $latestVersionObj->isGreaterThan($current); + $latestVersion = $latest['version']; + $latestTag = $latest['tag']; + } catch (\Exception) { + // Invalid version string + } + } + + // Determine if we can auto-update + $canAutoUpdate = $installInfo['supports_auto_update']; + $updateBlockers = []; + + if ($gitInfo['has_local_changes']) { + $canAutoUpdate = false; + $updateBlockers[] = 'local_changes'; + } + + if ($installInfo['type'] === InstallationType::DOCKER) { + $updateBlockers[] = 'docker_installation'; + } + + return [ + 'current_version' => $current->toString(), + 'latest_version' => $latestVersion, + 'latest_tag' => $latestTag, + 'update_available' => $updateAvailable, + 'release_notes' => $latest['body'] ?? null, + 'release_url' => $latest['url'] ?? null, + 'published_at' => $latest['published_at'] ?? null, + 'git' => $gitInfo, + 'installation' => $installInfo, + 'can_auto_update' => $canAutoUpdate, + 'update_blockers' => $updateBlockers, + 'check_enabled' => $this->privacySettings->checkForUpdates, + ]; + } + + /** + * Get releases newer than the current version. + */ + public function getAvailableUpdates(bool $includePrerelease = false): array + { + $releases = $this->getAvailableReleases(); + $current = $this->getCurrentVersion(); + $updates = []; + + foreach ($releases as $release) { + if ($release['draft']) { + continue; + } + + if (!$includePrerelease && $release['prerelease']) { + continue; + } + + try { + $releaseVersion = Version::fromString($release['version']); + if ($releaseVersion->isGreaterThan($current)) { + $updates[] = $release; + } + } catch (\Exception) { + continue; + } + } + + return $updates; + } +} diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php new file mode 100644 index 00000000..7bc997f7 --- /dev/null +++ b/src/Services/System/UpdateExecutor.php @@ -0,0 +1,832 @@ +. + */ + +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. + */ +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 BACKUP_DIR = 'var/backups'; + 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 VersionManagerInterface $versionManager) + { + + } + + /** + * Get the current version string for use in filenames. + */ + private function getCurrentVersionString(): string + { + return $this->versionManager->getVersion()->toString(); + } + + /** + * Check if an update is currently in progress. + */ + public function isLocked(): bool + { + $lockFile = $this->project_dir . '/' . self::LOCK_FILE; + + if (!file_exists($lockFile)) { + return false; + } + + // Check if lock is stale (older than 1 hour) + $lockData = json_decode(file_get_contents($lockFile), true); + 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. + */ + 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); + } + + /** + * Check if maintenance mode is enabled. + */ + public function isMaintenanceMode(): bool + { + return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE); + } + + /** + * Get maintenance mode information. + */ + 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); + } + + /** + * 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_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_PRETTY_PRINT)); + } + + /** + * Disable maintenance mode. + */ + public function disableMaintenanceMode(): void + { + $maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE; + + if (file_exists($maintenanceFile)) { + $this->filesystem->remove($maintenanceFile); + } + } + + /** + * 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 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->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 dependencies + $stepStart = microtime(true); + $this->runCommand([ + 'composer', 'install', + '--no-dev', + '--optimize-autoloader', + '--no-interaction', + '--no-progress', + ], 'Install dependencies', 600); + $log('composer', 'Installed/updated dependencies', true, microtime(true) - $stepStart); + + // Step 8: 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 9: Clear cache + $stepStart = microtime(true); + $this->runCommand([ + 'php', 'bin/console', 'cache:clear', + '--env=prod', + '--no-interaction', + ], 'Clear cache', 120); + $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); + + // Step 10: 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 11: Disable maintenance mode + $stepStart = microtime(true); + $this->disableMaintenanceMode(); + $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); + + // Step 12: 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 dependencies after rollback', true); + + // Clear cache after rollback + $this->runCommand([ + 'php', 'bin/console', 'cache:clear', + '--env=prod', + ], 'Clear cache after rollback', 120); + $log('rollback_cache', 'Cleared cache after rollback', true); + + } 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, + ]; + } + } + + /** + * Create a backup before updating. + */ + private function createBackup(string $targetVersion): string + { + $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; + + if (!is_dir($backupDir)) { + $this->filesystem->mkdir($backupDir, 0755); + } + + // Include version numbers in backup filename: pre-update-v2.5.1-to-v2.6.0-2024-01-30-185400.zip + $currentVersion = $this->getCurrentVersionString(); + $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); + $backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip'; + + $this->runCommand([ + 'php', 'bin/console', 'partdb:backup', + '--full', + '--overwrite', + $backupFile, + ], 'Create backup', 600); + + return $backupFile; + } + + /** + * Run a shell command with proper error handling. + */ + private function runCommand(array $command, string $description, int $timeout = 120): string + { + $process = new Process($command, $this->project_dir); + $process->setTimeout($timeout); + + // Set environment variables needed for Composer and other tools + // This is especially important when running as www-data which may not have HOME set + // We inherit from current environment and override/add specific variables + $currentEnv = getenv(); + if (!is_array($currentEnv)) { + $currentEnv = []; + } + $env = array_merge($currentEnv, [ + 'HOME' => $this->project_dir, + 'COMPOSER_HOME' => $this->project_dir . '/var/composer', + 'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin', + ]); + $process->setEnv($env); + + $output = ''; + $process->run(function ($type, $buffer) use (&$output) { + $output .= $buffer; + }); + + if (!$process->isSuccessful()) { + $errorOutput = $process->getErrorOutput() ?: $process->getOutput(); + throw new \RuntimeException( + sprintf('%s failed: %s', $description, trim($errorOutput)) + ); + } + + return $output; + } + + /** + * 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. + */ + 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, fn($a, $b) => $b['date'] <=> $a['date']); + + return $logs; + } + + /** + * Get list of backups. + */ + public function getBackups(): array + { + $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; + + if (!is_dir($backupDir)) { + return []; + } + + $backups = []; + foreach (glob($backupDir . '/*.zip') as $backupFile) { + $backups[] = [ + 'file' => basename($backupFile), + 'path' => $backupFile, + 'date' => filemtime($backupFile), + 'size' => filesize($backupFile), + ]; + } + + // Sort by date descending + usort($backups, fn($a, $b) => $b['date'] <=> $a['date']); + + return $backups; + } + + /** + * 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. + */ + public 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_PRETTY_PRINT)); + } + + /** + * Get current update progress from file. + */ + public function getProgress(): ?array + { + $progressFile = $this->getProgressFilePath(); + + if (!file_exists($progressFile)) { + return null; + } + + $data = json_decode(file_get_contents($progressFile), true); + + // 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' => 12, + '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); + } + + // 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 + $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 = 12; + $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; + } +} diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 37a09b09..1bb81bf7 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -315,6 +315,13 @@ class ToolsTreeBuilder ))->setIcon('fa fa-fw fa-gears fa-solid'); } + if ($this->security->isGranted('@system.show_updates')) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('tree.tools.system.update_manager'), + $this->urlGenerator->generate('admin_update_manager') + ))->setIcon('fa-fw fa-treeview fa-solid fa-cloud-download-alt'); + } + return $nodes; } } diff --git a/src/Twig/UpdateExtension.php b/src/Twig/UpdateExtension.php new file mode 100644 index 00000000..10264d12 --- /dev/null +++ b/src/Twig/UpdateExtension.php @@ -0,0 +1,79 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Twig; + +use App\Services\System\UpdateAvailableManager; +use Symfony\Bundle\SecurityBundle\Security; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * Twig extension for update-related functions. + */ +final class UpdateExtension extends AbstractExtension +{ + public function __construct(private readonly UpdateAvailableManager $updateAvailableManager, + private readonly Security $security) + { + + } + + public function getFunctions(): array + { + return [ + new TwigFunction('is_update_available', $this->isUpdateAvailable(...)), + new TwigFunction('get_latest_version', $this->getLatestVersion(...)), + new TwigFunction('get_latest_version_url', $this->getLatestVersionUrl(...)), + ]; + } + + /** + * Check if an update is available and the user has permission to see it. + */ + public function isUpdateAvailable(): bool + { + // Only show to users with the show_updates permission + if (!$this->security->isGranted('@system.show_updates')) { + return false; + } + + return $this->updateAvailableManager->isUpdateAvailable(); + } + + /** + * Get the latest available version string. + */ + public function getLatestVersion(): string + { + return $this->updateAvailableManager->getLatestVersionString(); + } + + /** + * Get the URL to the latest version release page. + */ + public function getLatestVersionUrl(): string + { + return $this->updateAvailableManager->getLatestVersionUrl(); + } +} diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig index 446ccdab..30562ec4 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -74,6 +74,19 @@