From 10c192edd1697929e50bb52cfb83a50263c341dd Mon Sep 17 00:00:00 2001 From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:17:22 +0100 Subject: [PATCH] Address PR feedback: add yarn build, env vars, and BackupManager Changes based on maintainer feedback from PR #1217: 1. Add yarn install/build steps to update process - Added yarn availability check in validateUpdatePreconditions - Added yarn install and yarn build steps after composer install - Added yarn rebuild to rollback process - Updated total steps count from 12 to 14 2. Add environment variables to disable web features - DISABLE_WEB_UPDATES: Completely disable web-based updates - DISABLE_BACKUP_RESTORE: Disable backup restore from web UI - Added checks in controller and template 3. Extract BackupManager service - New service handles backup creation, listing, details, and restoration - UpdateExecutor now delegates backup operations to BackupManager - Cleaner separation of concerns for future reuse 4. Merge upstream/master and resolve translation conflicts - Added Conrad info provider and generic web provider translations - Kept Update Manager translations --- .env | 11 + src/Controller/UpdateManagerController.php | 36 +- src/Services/System/BackupManager.php | 487 ++++++++++++++++++ src/Services/System/UpdateExecutor.php | 371 +++---------- .../admin/update_manager/index.html.twig | 23 +- translations/messages.en.xlf | 18 + 6 files changed, 653 insertions(+), 293 deletions(-) create mode 100644 src/Services/System/BackupManager.php diff --git a/.env b/.env index 9a6ce846..3196241b 100644 --- a/.env +++ b/.env @@ -59,6 +59,17 @@ ERROR_PAGE_ADMIN_EMAIL='' # If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them... ERROR_PAGE_SHOW_HELP=1 +################################################################################### +# Update Manager settings +################################################################################### + +# Set this to 1 to completely disable web-based updates, regardless of user permissions. +# Use this if you prefer to manage updates through your own deployment process. +DISABLE_WEB_UPDATES=0 + +# Set this to 1 to disable the backup restore feature from the web UI. +# Restoring backups is a destructive operation that could cause data loss. +DISABLE_BACKUP_RESTORE=0 ################################################################################### # SAML Single sign on-settings diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 8455516a..b247cb38 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -27,9 +27,11 @@ use App\Services\System\UpdateChecker; use App\Services\System\UpdateExecutor; use Shivas\VersioningBundle\Service\VersionManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Attribute\Route; /** @@ -41,11 +43,35 @@ use Symfony\Component\Routing\Attribute\Route; #[Route('/admin/update-manager')] class UpdateManagerController extends AbstractController { - public function __construct(private readonly UpdateChecker $updateChecker, + public function __construct( + private readonly UpdateChecker $updateChecker, private readonly UpdateExecutor $updateExecutor, - private readonly VersionManagerInterface $versionManager) - { + private readonly VersionManagerInterface $versionManager, + #[Autowire(env: 'bool:DISABLE_WEB_UPDATES')] + private readonly bool $webUpdatesDisabled = false, + #[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')] + private readonly bool $backupRestoreDisabled = false, + ) { + } + /** + * Check if web updates are disabled and throw exception if so. + */ + private function denyIfWebUpdatesDisabled(): void + { + if ($this->webUpdatesDisabled) { + throw new AccessDeniedHttpException('Web-based updates are disabled by server configuration. Please use the CLI command instead.'); + } + } + + /** + * Check if backup restore is disabled and throw exception if so. + */ + private function denyIfBackupRestoreDisabled(): void + { + if ($this->backupRestoreDisabled) { + throw new AccessDeniedHttpException('Backup restore is disabled by server configuration.'); + } } /** @@ -71,6 +97,8 @@ class UpdateManagerController extends AbstractController 'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(), 'update_logs' => $this->updateExecutor->getUpdateLogs(), 'backups' => $this->updateExecutor->getBackups(), + 'web_updates_disabled' => $this->webUpdatesDisabled, + 'backup_restore_disabled' => $this->backupRestoreDisabled, ]); } @@ -177,6 +205,7 @@ class UpdateManagerController extends AbstractController public function startUpdate(Request $request): Response { $this->denyAccessUnlessGranted('@system.manage_updates'); + $this->denyIfWebUpdatesDisabled(); // Validate CSRF token if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_token'))) { @@ -290,6 +319,7 @@ class UpdateManagerController extends AbstractController public function restore(Request $request): Response { $this->denyAccessUnlessGranted('@system.manage_updates'); + $this->denyIfBackupRestoreDisabled(); // Validate CSRF token if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) { diff --git a/src/Services/System/BackupManager.php b/src/Services/System/BackupManager.php new file mode 100644 index 00000000..b646e433 --- /dev/null +++ b/src/Services/System/BackupManager.php @@ -0,0 +1,487 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\System; + +use Doctrine\ORM\EntityManagerInterface; +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; + +/** + * Manages Part-DB backups: creation, restoration, and listing. + * + * This service handles all backup-related operations and can be used + * by the Update Manager, CLI commands, or other services. + */ +class BackupManager +{ + private const BACKUP_DIR = 'var/backups'; + + public function __construct( + #[Autowire(param: 'kernel.project_dir')] + private readonly string $projectDir, + private readonly LoggerInterface $logger, + private readonly Filesystem $filesystem, + private readonly VersionManagerInterface $versionManager, + private readonly EntityManagerInterface $entityManager, + ) { + } + + /** + * Get the backup directory path. + */ + public function getBackupDir(): string + { + return $this->projectDir . '/' . self::BACKUP_DIR; + } + + /** + * Get the current version string for use in filenames. + */ + private function getCurrentVersionString(): string + { + return $this->versionManager->getVersion()->toString(); + } + + /** + * Create a backup before updating. + * + * @param string|null $targetVersion Optional target version for naming + * @param string|null $prefix Optional prefix for the backup filename + * @return string The path to the created backup file + */ + public function createBackup(?string $targetVersion = null, ?string $prefix = 'backup'): string + { + $backupDir = $this->getBackupDir(); + + if (!is_dir($backupDir)) { + $this->filesystem->mkdir($backupDir, 0755); + } + + $currentVersion = $this->getCurrentVersionString(); + + // Build filename + if ($targetVersion) { + $targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion); + $backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip'; + } else { + $backupFile = $backupDir . '/' . $prefix . '-v' . $currentVersion . '-' . date('Y-m-d-His') . '.zip'; + } + + $this->runCommand([ + 'php', 'bin/console', 'partdb:backup', + '--full', + '--overwrite', + $backupFile, + ], 'Create backup', 600); + + $this->logger->info('Created backup', ['file' => $backupFile]); + + return $backupFile; + } + + /** + * Get list of backups. + * + * @return array + */ + public function getBackups(): array + { + $backupDir = $this->getBackupDir(); + + 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 details about a specific backup file. + * + * @param string $filename The backup filename + * @return array|null Backup details or null if not found + */ + public function getBackupDetails(string $filename): ?array + { + $backupDir = $this->getBackupDir(); + $backupPath = $backupDir . '/' . basename($filename); + + if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) { + return null; + } + + // Parse version info from filename: pre-update-v2.5.1-to-v2.5.0-2024-01-30-185400.zip + $info = [ + 'file' => basename($backupPath), + 'path' => $backupPath, + 'date' => filemtime($backupPath), + 'size' => filesize($backupPath), + 'from_version' => null, + 'to_version' => null, + ]; + + if (preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches)) { + $info['from_version'] = $matches[1]; + $info['to_version'] = $matches[2]; + } + + // Check what the backup contains by reading the ZIP + try { + $zip = new \ZipArchive(); + if ($zip->open($backupPath) === true) { + $info['contains_database'] = $zip->locateName('database.sql') !== false || $zip->locateName('var/app.db') !== false; + $info['contains_config'] = $zip->locateName('.env.local') !== false || $zip->locateName('config/parameters.yaml') !== false; + $info['contains_attachments'] = $zip->locateName('public/media/') !== false || $zip->locateName('uploads/') !== false; + $zip->close(); + } + } catch (\Exception $e) { + $this->logger->warning('Could not read backup ZIP contents', ['error' => $e->getMessage()]); + } + + return $info; + } + + /** + * Delete a backup file. + * + * @param string $filename The backup filename to delete + * @return bool True if deleted successfully + */ + public function deleteBackup(string $filename): bool + { + $backupDir = $this->getBackupDir(); + $backupPath = $backupDir . '/' . basename($filename); + + if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) { + return false; + } + + try { + $this->filesystem->remove($backupPath); + $this->logger->info('Deleted backup', ['file' => $filename]); + return true; + } catch (\Exception $e) { + $this->logger->error('Failed to delete backup', ['file' => $filename, 'error' => $e->getMessage()]); + return false; + } + } + + /** + * Restore from a backup file. + * + * @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 { + $steps = []; + $startTime = microtime(true); + + $log = function (string $step, string $message, bool $success, ?float $duration = null) use (&$steps, $onProgress): void { + $entry = [ + 'step' => $step, + 'message' => $message, + 'success' => $success, + 'timestamp' => (new \DateTime())->format('c'), + 'duration' => $duration, + ]; + $steps[] = $entry; + $this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]); + + if ($onProgress) { + $onProgress($entry); + } + }; + + try { + // Validate backup file + $backupDir = $this->getBackupDir(); + $backupPath = $backupDir . '/' . basename($filename); + + if (!file_exists($backupPath)) { + throw new \RuntimeException('Backup file not found: ' . $filename); + } + + $stepStart = microtime(true); + + // Step 1: Extract backup to temp directory + $tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid(); + $this->filesystem->mkdir($tempDir); + + $zip = new \ZipArchive(); + if ($zip->open($backupPath) !== true) { + throw new \RuntimeException('Could not open backup ZIP file'); + } + $zip->extractTo($tempDir); + $zip->close(); + $log('extract', 'Extracted backup to temporary directory', true, microtime(true) - $stepStart); + + // Step 2: Restore database if requested and present + if ($restoreDatabase) { + $stepStart = microtime(true); + $this->restoreDatabaseFromBackup($tempDir); + $log('database', 'Restored database', true, microtime(true) - $stepStart); + } + + // Step 3: Restore config files if requested and present + if ($restoreConfig) { + $stepStart = microtime(true); + $this->restoreConfigFromBackup($tempDir); + $log('config', 'Restored configuration files', true, microtime(true) - $stepStart); + } + + // Step 4: Restore attachments if requested and present + if ($restoreAttachments) { + $stepStart = microtime(true); + $this->restoreAttachmentsFromBackup($tempDir); + $log('attachments', 'Restored attachments', true, microtime(true) - $stepStart); + } + + // Step 5: Clean up temp directory + $stepStart = microtime(true); + $this->filesystem->remove($tempDir); + $log('cleanup', 'Cleaned up temporary files', true, microtime(true) - $stepStart); + + $totalDuration = microtime(true) - $startTime; + $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true); + + return [ + 'success' => true, + 'steps' => $steps, + 'error' => null, + ]; + + } catch (\Throwable $e) { + $this->logger->error('Restore failed: ' . $e->getMessage(), [ + 'exception' => $e, + 'file' => $filename, + ]); + + // Try to clean up + try { + if (isset($tempDir) && is_dir($tempDir)) { + $this->filesystem->remove($tempDir); + } + } catch (\Throwable $cleanupError) { + $this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]); + } + + return [ + 'success' => false, + 'steps' => $steps, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Restore database from backup. + */ + private function restoreDatabaseFromBackup(string $tempDir): void + { + // Check for SQL dump (MySQL/PostgreSQL) + $sqlFile = $tempDir . '/database.sql'; + if (file_exists($sqlFile)) { + // Import SQL using mysql/psql command directly + // First, get database connection params from Doctrine + $connection = $this->entityManager->getConnection(); + $params = $connection->getParams(); + $platform = $connection->getDatabasePlatform(); + + if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { + // Use mysql command to import - need to use shell to handle input redirection + $mysqlCmd = 'mysql'; + if (isset($params['host'])) { + $mysqlCmd .= ' -h ' . escapeshellarg($params['host']); + } + if (isset($params['port'])) { + $mysqlCmd .= ' -P ' . escapeshellarg((string)$params['port']); + } + if (isset($params['user'])) { + $mysqlCmd .= ' -u ' . escapeshellarg($params['user']); + } + if (isset($params['password']) && $params['password']) { + $mysqlCmd .= ' -p' . escapeshellarg($params['password']); + } + if (isset($params['dbname'])) { + $mysqlCmd .= ' ' . escapeshellarg($params['dbname']); + } + $mysqlCmd .= ' < ' . escapeshellarg($sqlFile); + + // Execute using shell + $process = Process::fromShellCommandline($mysqlCmd, $this->projectDir, null, null, 300); + $process->run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput()); + } + } elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) { + // Use psql command to import + $psqlCmd = 'psql'; + if (isset($params['host'])) { + $psqlCmd .= ' -h ' . escapeshellarg($params['host']); + } + if (isset($params['port'])) { + $psqlCmd .= ' -p ' . escapeshellarg((string)$params['port']); + } + if (isset($params['user'])) { + $psqlCmd .= ' -U ' . escapeshellarg($params['user']); + } + if (isset($params['dbname'])) { + $psqlCmd .= ' -d ' . escapeshellarg($params['dbname']); + } + $psqlCmd .= ' -f ' . escapeshellarg($sqlFile); + + // Set PGPASSWORD environment variable if password is provided + $env = null; + if (isset($params['password']) && $params['password']) { + $env = ['PGPASSWORD' => $params['password']]; + } + + // Execute using shell + $process = Process::fromShellCommandline($psqlCmd, $this->projectDir, $env, null, 300); + $process->run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException('PostgreSQL import failed: ' . $process->getErrorOutput()); + } + } else { + throw new \RuntimeException('Unsupported database platform for restore'); + } + + return; + } + + // Check for SQLite database file + $sqliteFile = $tempDir . '/var/app.db'; + if (file_exists($sqliteFile)) { + $targetDb = $this->projectDir . '/var/app.db'; + $this->filesystem->copy($sqliteFile, $targetDb, true); + return; + } + + $this->logger->warning('No database found in backup'); + } + + /** + * Restore config files from backup. + */ + private function restoreConfigFromBackup(string $tempDir): void + { + // Restore .env.local + $envLocal = $tempDir . '/.env.local'; + if (file_exists($envLocal)) { + $this->filesystem->copy($envLocal, $this->projectDir . '/.env.local', true); + } + + // Restore config/parameters.yaml + $parametersYaml = $tempDir . '/config/parameters.yaml'; + if (file_exists($parametersYaml)) { + $this->filesystem->copy($parametersYaml, $this->projectDir . '/config/parameters.yaml', true); + } + + // Restore config/banner.md + $bannerMd = $tempDir . '/config/banner.md'; + if (file_exists($bannerMd)) { + $this->filesystem->copy($bannerMd, $this->projectDir . '/config/banner.md', true); + } + } + + /** + * Restore attachments from backup. + */ + private function restoreAttachmentsFromBackup(string $tempDir): void + { + // Restore public/media + $publicMedia = $tempDir . '/public/media'; + if (is_dir($publicMedia)) { + $this->filesystem->mirror($publicMedia, $this->projectDir . '/public/media', null, ['override' => true]); + } + + // Restore uploads + $uploads = $tempDir . '/uploads'; + if (is_dir($uploads)) { + $this->filesystem->mirror($uploads, $this->projectDir . '/uploads', null, ['override' => true]); + } + } + + /** + * Run a shell command with proper error handling. + */ + private function runCommand(array $command, string $description, int $timeout = 120): string + { + $process = new Process($command, $this->projectDir); + $process->setTimeout($timeout); + + // Set environment variables + $currentEnv = getenv(); + if (!is_array($currentEnv)) { + $currentEnv = []; + } + $env = array_merge($currentEnv, [ + 'HOME' => $this->projectDir, + 'COMPOSER_HOME' => $this->projectDir . '/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; + } +} diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 837cde4c..84113981 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -23,7 +23,6 @@ declare(strict_types=1); namespace App\Services\System; -use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Shivas\VersioningBundle\Service\VersionManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -41,7 +40,6 @@ 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 */ @@ -49,13 +47,15 @@ class UpdateExecutor 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, + 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, - private readonly EntityManagerInterface $entityManager) - { - + private readonly BackupManager $backupManager, + ) { } /** @@ -252,6 +252,13 @@ class UpdateExecutor $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) { @@ -345,7 +352,7 @@ class UpdateExecutor // Step 4: Create backup (optional) if ($createBackup) { $stepStart = microtime(true); - $backupFile = $this->createBackup($targetVersion); + $backupFile = $this->backupManager->createBackup($targetVersion); $log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart); } @@ -359,7 +366,7 @@ class UpdateExecutor $this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version'); $log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart); - // Step 7: Install dependencies + // Step 7: Install PHP dependencies $stepStart = microtime(true); $this->runCommand([ 'composer', 'install', @@ -367,10 +374,26 @@ class UpdateExecutor '--optimize-autoloader', '--no-interaction', '--no-progress', - ], 'Install dependencies', 600); - $log('composer', 'Installed/updated dependencies', true, microtime(true) - $stepStart); + ], 'Install PHP dependencies', 600); + $log('composer', 'Installed/updated PHP dependencies', true, microtime(true) - $stepStart); - // Step 8: Run database migrations + // 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', @@ -379,7 +402,7 @@ class UpdateExecutor ], 'Run migrations', 300); $log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart); - // Step 9: Clear cache + // Step 11: Clear cache $stepStart = microtime(true); $this->runCommand([ 'php', 'bin/console', 'cache:clear', @@ -388,7 +411,7 @@ class UpdateExecutor ], 'Clear cache', 120); $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); - // Step 10: Warm up cache + // Step 12: Warm up cache $stepStart = microtime(true); $this->runCommand([ 'php', 'bin/console', 'cache:warmup', @@ -396,12 +419,12 @@ class UpdateExecutor ], 'Warmup cache', 120); $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); - // Step 11: Disable maintenance mode + // Step 13: Disable maintenance mode $stepStart = microtime(true); $this->disableMaintenanceMode(); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); - // Step 12: Release lock + // Step 14: Release lock $stepStart = microtime(true); $this->releaseLock(); @@ -433,7 +456,21 @@ class UpdateExecutor '--optimize-autoloader', '--no-interaction', ], 'Reinstall dependencies after rollback', 600); - $log('rollback_composer', 'Reinstalled dependencies after rollback', true); + $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([ @@ -462,32 +499,6 @@ class UpdateExecutor } } - /** - * 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. */ @@ -605,79 +616,27 @@ class UpdateExecutor /** * Get list of backups. + * @deprecated Use BackupManager::getBackups() directly */ 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; + return $this->backupManager->getBackups(); } /** * Get details about a specific backup file. - * - * @param string $filename The backup filename - * @return array|null Backup details or null if not found + * @deprecated Use BackupManager::getBackupDetails() directly */ public function getBackupDetails(string $filename): ?array { - $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; - $backupPath = $backupDir . '/' . basename($filename); - - if (!file_exists($backupPath) || !str_ends_with($backupPath, '.zip')) { - return null; - } - - // Parse version info from filename: pre-update-v2.5.1-to-v2.5.0-2024-01-30-185400.zip - $info = [ - 'file' => basename($backupPath), - 'path' => $backupPath, - 'date' => filemtime($backupPath), - 'size' => filesize($backupPath), - 'from_version' => null, - 'to_version' => null, - ]; - - if (preg_match('/pre-update-v([\d.]+)-to-v?([\d.]+)-/', $filename, $matches)) { - $info['from_version'] = $matches[1]; - $info['to_version'] = $matches[2]; - } - - // Check what the backup contains by reading the ZIP - try { - $zip = new \ZipArchive(); - if ($zip->open($backupPath) === true) { - $info['contains_database'] = $zip->locateName('database.sql') !== false || $zip->locateName('var/app.db') !== false; - $info['contains_config'] = $zip->locateName('.env.local') !== false || $zip->locateName('config/parameters.yaml') !== false; - $info['contains_attachments'] = $zip->locateName('public/media/') !== false || $zip->locateName('uploads/') !== false; - $zip->close(); - } - } catch (\Exception $e) { - $this->logger->warning('Could not read backup ZIP contents', ['error' => $e->getMessage()]); - } - - return $info; + return $this->backupManager->getBackupDetails($filename); } /** - * Restore from a backup file. + * 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 @@ -713,18 +672,12 @@ class UpdateExecutor }; try { - // Validate backup file - $backupDir = $this->project_dir . '/' . self::BACKUP_DIR; - $backupPath = $backupDir . '/' . basename($filename); - - if (!file_exists($backupPath)) { - throw new \RuntimeException('Backup file not found: ' . $filename); - } - $stepStart = microtime(true); // Step 1: Acquire lock - $this->acquireLock('restore'); + 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 @@ -732,65 +685,43 @@ class UpdateExecutor $this->enableMaintenanceMode('Restoring from backup...'); $log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart); - // Step 3: Extract backup to temp directory + // Step 3: Delegate to BackupManager for core restoration $stepStart = microtime(true); - $tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid(); - $this->filesystem->mkdir($tempDir); + $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); + } + ); - $zip = new \ZipArchive(); - if ($zip->open($backupPath) !== true) { - throw new \RuntimeException('Could not open backup ZIP file'); - } - $zip->extractTo($tempDir); - $zip->close(); - $log('extract', 'Extracted backup to temporary directory', true, microtime(true) - $stepStart); - - // Step 4: Restore database if requested and present - if ($restoreDatabase) { - $stepStart = microtime(true); - $this->restoreDatabaseFromBackup($tempDir); - $log('database', 'Restored database', true, microtime(true) - $stepStart); + if (!$result['success']) { + throw new \RuntimeException($result['error'] ?? 'Restore failed'); } - // Step 5: Restore config files if requested and present - if ($restoreConfig) { - $stepStart = microtime(true); - $this->restoreConfigFromBackup($tempDir); - $log('config', 'Restored configuration files', true, microtime(true) - $stepStart); - } - - // Step 6: Restore attachments if requested and present - if ($restoreAttachments) { - $stepStart = microtime(true); - $this->restoreAttachmentsFromBackup($tempDir); - $log('attachments', 'Restored attachments', true, microtime(true) - $stepStart); - } - - // Step 7: Clean up temp directory - $stepStart = microtime(true); - $this->filesystem->remove($tempDir); - $log('cleanup', 'Cleaned up temporary files', true, microtime(true) - $stepStart); - - // Step 8: Clear cache + // 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 9: Warm up cache + // 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 10: Disable maintenance mode + // Step 6: Disable maintenance mode $stepStart = microtime(true); $this->disableMaintenanceMode(); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); - // Step 11: Release lock + // Step 7: Release lock $this->releaseLock(); $totalDuration = microtime(true) - $startTime; - $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart); + $log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true); return [ 'success' => true, @@ -808,9 +739,6 @@ class UpdateExecutor try { $this->disableMaintenanceMode(); $this->releaseLock(); - if (isset($tempDir) && is_dir($tempDir)) { - $this->filesystem->remove($tempDir); - } } catch (\Throwable $cleanupError) { $this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]); } @@ -823,137 +751,6 @@ class UpdateExecutor } } - /** - * Restore database from backup. - */ - private function restoreDatabaseFromBackup(string $tempDir): void - { - // Check for SQL dump (MySQL/PostgreSQL) - $sqlFile = $tempDir . '/database.sql'; - if (file_exists($sqlFile)) { - // Import SQL using mysql/psql command directly - // First, get database connection params from Doctrine - $connection = $this->entityManager->getConnection(); - $params = $connection->getParams(); - $platform = $connection->getDatabasePlatform(); - - if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { - // Use mysql command to import - need to use shell to handle input redirection - $mysqlCmd = 'mysql'; - if (isset($params['host'])) { - $mysqlCmd .= ' -h ' . escapeshellarg($params['host']); - } - if (isset($params['port'])) { - $mysqlCmd .= ' -P ' . escapeshellarg((string)$params['port']); - } - if (isset($params['user'])) { - $mysqlCmd .= ' -u ' . escapeshellarg($params['user']); - } - if (isset($params['password']) && $params['password']) { - $mysqlCmd .= ' -p' . escapeshellarg($params['password']); - } - if (isset($params['dbname'])) { - $mysqlCmd .= ' ' . escapeshellarg($params['dbname']); - } - $mysqlCmd .= ' < ' . escapeshellarg($sqlFile); - - // Execute using shell - $process = Process::fromShellCommandline($mysqlCmd, $this->project_dir, null, null, 300); - $process->run(); - - if (!$process->isSuccessful()) { - throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput()); - } - } elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) { - // Use psql command to import - $psqlCmd = 'psql'; - if (isset($params['host'])) { - $psqlCmd .= ' -h ' . escapeshellarg($params['host']); - } - if (isset($params['port'])) { - $psqlCmd .= ' -p ' . escapeshellarg((string)$params['port']); - } - if (isset($params['user'])) { - $psqlCmd .= ' -U ' . escapeshellarg($params['user']); - } - if (isset($params['dbname'])) { - $psqlCmd .= ' -d ' . escapeshellarg($params['dbname']); - } - $psqlCmd .= ' -f ' . escapeshellarg($sqlFile); - - // Set PGPASSWORD environment variable if password is provided - $env = null; - if (isset($params['password']) && $params['password']) { - $env = ['PGPASSWORD' => $params['password']]; - } - - // Execute using shell - $process = Process::fromShellCommandline($psqlCmd, $this->project_dir, $env, null, 300); - $process->run(); - - if (!$process->isSuccessful()) { - throw new \RuntimeException('PostgreSQL import failed: ' . $process->getErrorOutput()); - } - } else { - throw new \RuntimeException('Unsupported database platform for restore'); - } - - return; - } - - // Check for SQLite database file - $sqliteFile = $tempDir . '/var/app.db'; - if (file_exists($sqliteFile)) { - $targetDb = $this->project_dir . '/var/app.db'; - $this->filesystem->copy($sqliteFile, $targetDb, true); - return; - } - - $this->logger->warning('No database found in backup'); - } - - /** - * Restore config files from backup. - */ - private function restoreConfigFromBackup(string $tempDir): void - { - // Restore .env.local - $envLocal = $tempDir . '/.env.local'; - if (file_exists($envLocal)) { - $this->filesystem->copy($envLocal, $this->project_dir . '/.env.local', true); - } - - // Restore config/parameters.yaml - $parametersYaml = $tempDir . '/config/parameters.yaml'; - if (file_exists($parametersYaml)) { - $this->filesystem->copy($parametersYaml, $this->project_dir . '/config/parameters.yaml', true); - } - - // Restore config/banner.md - $bannerMd = $tempDir . '/config/banner.md'; - if (file_exists($bannerMd)) { - $this->filesystem->copy($bannerMd, $this->project_dir . '/config/banner.md', true); - } - } - - /** - * Restore attachments from backup. - */ - private function restoreAttachmentsFromBackup(string $tempDir): void - { - // Restore public/media - $publicMedia = $tempDir . '/public/media'; - if (is_dir($publicMedia)) { - $this->filesystem->mirror($publicMedia, $this->project_dir . '/public/media', null, ['override' => true]); - } - - // Restore uploads - $uploads = $tempDir . '/uploads'; - if (is_dir($uploads)) { - $this->filesystem->mirror($uploads, $this->project_dir . '/uploads', null, ['override' => true]); - } - } - /** * Get the path to the progress file. */ @@ -1048,7 +845,7 @@ class UpdateExecutor 'create_backup' => $createBackup, 'started_at' => (new \DateTime())->format('c'), 'current_step' => 0, - 'total_steps' => 12, + 'total_steps' => 14, 'step_name' => 'initializing', 'step_message' => 'Starting update process...', 'steps' => [], diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 85b3ec1f..24dfcc96 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -34,6 +34,23 @@ {% endif %} + {# Web Updates Disabled Warning #} + {% if web_updates_disabled %} + + {% endif %} + + {# Backup Restore Disabled Warning #} + {% if backup_restore_disabled %} + + {% endif %} +
{# Current Version Card #}
@@ -132,7 +149,7 @@ {% endif %}
- {% if status.update_available and status.can_auto_update and validation.valid %} + {% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
- {% if release.version != status.current_version and status.can_auto_update and validation.valid %} + {% if release.version != status.current_version and status.can_auto_update and validation.valid and not web_updates_disabled %} - {% if status.can_auto_update and validation.valid %} + {% if status.can_auto_update and validation.valid and not backup_restore_disabled %} WARNING: This will overwrite your current database with the backup data. This action cannot be undone! Make sure you have a current backup before proceeding. + + + update_manager.web_updates_disabled + Web-based updates are disabled + + + + + update_manager.web_updates_disabled_hint + Web-based updates have been disabled by the server administrator. Please use the CLI command "php bin/console partdb:update" to perform updates. + + + + + update_manager.backup_restore_disabled + Backup restore is disabled by server configuration. + + settings.ips.conrad