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
This commit is contained in:
Sebastian Almberg 2026-02-01 19:17:22 +01:00
parent 6b27f3aa14
commit 10c192edd1
6 changed files with 653 additions and 293 deletions

11
.env
View file

@ -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... # 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 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 # SAML Single sign on-settings

View file

@ -27,9 +27,11 @@ use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor; use App\Services\System\UpdateExecutor;
use Shivas\VersioningBundle\Service\VersionManagerInterface; use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
@ -41,11 +43,35 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/admin/update-manager')] #[Route('/admin/update-manager')]
class UpdateManagerController extends AbstractController class UpdateManagerController extends AbstractController
{ {
public function __construct(private readonly UpdateChecker $updateChecker, public function __construct(
private readonly UpdateChecker $updateChecker,
private readonly UpdateExecutor $updateExecutor, 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(), 'maintenance_info' => $this->updateExecutor->getMaintenanceInfo(),
'update_logs' => $this->updateExecutor->getUpdateLogs(), 'update_logs' => $this->updateExecutor->getUpdateLogs(),
'backups' => $this->updateExecutor->getBackups(), '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 public function startUpdate(Request $request): Response
{ {
$this->denyAccessUnlessGranted('@system.manage_updates'); $this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
// Validate CSRF token // Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_start', $request->request->get('_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 public function restore(Request $request): Response
{ {
$this->denyAccessUnlessGranted('@system.manage_updates'); $this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupRestoreDisabled();
// Validate CSRF token // Validate CSRF token
if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) { if (!$this->isCsrfTokenValid('update_manager_restore', $request->request->get('_token'))) {

View file

@ -0,0 +1,487 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<array{file: string, path: string, date: int, size: int}>
*/
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;
}
}

View file

@ -23,7 +23,6 @@ declare(strict_types=1);
namespace App\Services\System; namespace App\Services\System;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface; use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
@ -41,7 +40,6 @@ class UpdateExecutor
private const LOCK_FILE = 'var/update.lock'; private const LOCK_FILE = 'var/update.lock';
private const MAINTENANCE_FILE = 'var/maintenance.flag'; private const MAINTENANCE_FILE = 'var/maintenance.flag';
private const UPDATE_LOG_DIR = 'var/log/updates'; private const UPDATE_LOG_DIR = 'var/log/updates';
private const BACKUP_DIR = 'var/backups';
private const PROGRESS_FILE = 'var/update_progress.json'; private const PROGRESS_FILE = 'var/update_progress.json';
/** @var array<array{step: string, message: string, success: bool, timestamp: string, duration: ?float}> */ /** @var array<array{step: string, message: string, success: bool, timestamp: string, duration: ?float}> */
@ -49,13 +47,15 @@ class UpdateExecutor
private ?string $currentLogFile = null; private ?string $currentLogFile = null;
public function __construct(#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir, public function __construct(
private readonly LoggerInterface $logger, private readonly Filesystem $filesystem, #[Autowire(param: 'kernel.project_dir')]
private readonly string $project_dir,
private readonly LoggerInterface $logger,
private readonly Filesystem $filesystem,
private readonly InstallationTypeDetector $installationTypeDetector, private readonly InstallationTypeDetector $installationTypeDetector,
private readonly VersionManagerInterface $versionManager, 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.'; $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 // Check write permissions
$testDirs = ['var', 'vendor', 'public']; $testDirs = ['var', 'vendor', 'public'];
foreach ($testDirs as $dir) { foreach ($testDirs as $dir) {
@ -345,7 +352,7 @@ class UpdateExecutor
// Step 4: Create backup (optional) // Step 4: Create backup (optional)
if ($createBackup) { if ($createBackup) {
$stepStart = microtime(true); $stepStart = microtime(true);
$backupFile = $this->createBackup($targetVersion); $backupFile = $this->backupManager->createBackup($targetVersion);
$log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart); $log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart);
} }
@ -359,7 +366,7 @@ class UpdateExecutor
$this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version'); $this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version');
$log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart); $log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart);
// Step 7: Install dependencies // Step 7: Install PHP dependencies
$stepStart = microtime(true); $stepStart = microtime(true);
$this->runCommand([ $this->runCommand([
'composer', 'install', 'composer', 'install',
@ -367,10 +374,26 @@ class UpdateExecutor
'--optimize-autoloader', '--optimize-autoloader',
'--no-interaction', '--no-interaction',
'--no-progress', '--no-progress',
], 'Install dependencies', 600); ], 'Install PHP dependencies', 600);
$log('composer', 'Installed/updated dependencies', true, microtime(true) - $stepStart); $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); $stepStart = microtime(true);
$this->runCommand([ $this->runCommand([
'php', 'bin/console', 'doctrine:migrations:migrate', 'php', 'bin/console', 'doctrine:migrations:migrate',
@ -379,7 +402,7 @@ class UpdateExecutor
], 'Run migrations', 300); ], 'Run migrations', 300);
$log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart); $log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart);
// Step 9: Clear cache // Step 11: Clear cache
$stepStart = microtime(true); $stepStart = microtime(true);
$this->runCommand([ $this->runCommand([
'php', 'bin/console', 'cache:clear', 'php', 'bin/console', 'cache:clear',
@ -388,7 +411,7 @@ class UpdateExecutor
], 'Clear cache', 120); ], 'Clear cache', 120);
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
// Step 10: Warm up cache // Step 12: Warm up cache
$stepStart = microtime(true); $stepStart = microtime(true);
$this->runCommand([ $this->runCommand([
'php', 'bin/console', 'cache:warmup', 'php', 'bin/console', 'cache:warmup',
@ -396,12 +419,12 @@ class UpdateExecutor
], 'Warmup cache', 120); ], 'Warmup cache', 120);
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
// Step 11: Disable maintenance mode // Step 13: Disable maintenance mode
$stepStart = microtime(true); $stepStart = microtime(true);
$this->disableMaintenanceMode(); $this->disableMaintenanceMode();
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
// Step 12: Release lock // Step 14: Release lock
$stepStart = microtime(true); $stepStart = microtime(true);
$this->releaseLock(); $this->releaseLock();
@ -433,7 +456,21 @@ class UpdateExecutor
'--optimize-autoloader', '--optimize-autoloader',
'--no-interaction', '--no-interaction',
], 'Reinstall dependencies after rollback', 600); ], '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 // Clear cache after rollback
$this->runCommand([ $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. * Run a shell command with proper error handling.
*/ */
@ -605,79 +616,27 @@ class UpdateExecutor
/** /**
* Get list of backups. * Get list of backups.
* @deprecated Use BackupManager::getBackups() directly
*/ */
public function getBackups(): array public function getBackups(): array
{ {
$backupDir = $this->project_dir . '/' . self::BACKUP_DIR; return $this->backupManager->getBackups();
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. * Get details about a specific backup file.
* * @deprecated Use BackupManager::getBackupDetails() directly
* @param string $filename The backup filename
* @return array|null Backup details or null if not found
*/ */
public function getBackupDetails(string $filename): ?array public function getBackupDetails(string $filename): ?array
{ {
$backupDir = $this->project_dir . '/' . self::BACKUP_DIR; return $this->backupManager->getBackupDetails($filename);
$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;
} }
/** /**
* 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 string $filename The backup filename to restore
* @param bool $restoreDatabase Whether to restore the database * @param bool $restoreDatabase Whether to restore the database
@ -713,18 +672,12 @@ class UpdateExecutor
}; };
try { 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); $stepStart = microtime(true);
// Step 1: Acquire lock // 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); $log('lock', 'Acquired exclusive restore lock', true, microtime(true) - $stepStart);
// Step 2: Enable maintenance mode // Step 2: Enable maintenance mode
@ -732,65 +685,43 @@ class UpdateExecutor
$this->enableMaintenanceMode('Restoring from backup...'); $this->enableMaintenanceMode('Restoring from backup...');
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart); $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); $stepStart = microtime(true);
$tempDir = sys_get_temp_dir() . '/partdb_restore_' . uniqid(); $result = $this->backupManager->restoreBackup(
$this->filesystem->mkdir($tempDir); $filename,
$restoreDatabase,
$zip = new \ZipArchive(); $restoreConfig,
if ($zip->open($backupPath) !== true) { $restoreAttachments,
throw new \RuntimeException('Could not open backup ZIP file'); function ($entry) use ($log) {
// Forward progress from BackupManager
$log($entry['step'], $entry['message'], $entry['success'], $entry['duration'] ?? null);
} }
$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 (!$result['success']) {
if ($restoreDatabase) { throw new \RuntimeException($result['error'] ?? 'Restore failed');
$stepStart = microtime(true);
$this->restoreDatabaseFromBackup($tempDir);
$log('database', 'Restored database', true, microtime(true) - $stepStart);
} }
// Step 5: Restore config files if requested and present // Step 4: Clear cache
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
$stepStart = microtime(true); $stepStart = microtime(true);
$this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache'); $this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache');
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart); $log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
// Step 9: Warm up cache // Step 5: Warm up cache
$stepStart = microtime(true); $stepStart = microtime(true);
$this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache'); $this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
// Step 10: Disable maintenance mode // Step 6: Disable maintenance mode
$stepStart = microtime(true); $stepStart = microtime(true);
$this->disableMaintenanceMode(); $this->disableMaintenanceMode();
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
// Step 11: Release lock // Step 7: Release lock
$this->releaseLock(); $this->releaseLock();
$totalDuration = microtime(true) - $startTime; $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 [ return [
'success' => true, 'success' => true,
@ -808,9 +739,6 @@ class UpdateExecutor
try { try {
$this->disableMaintenanceMode(); $this->disableMaintenanceMode();
$this->releaseLock(); $this->releaseLock();
if (isset($tempDir) && is_dir($tempDir)) {
$this->filesystem->remove($tempDir);
}
} catch (\Throwable $cleanupError) { } catch (\Throwable $cleanupError) {
$this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]); $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. * Get the path to the progress file.
*/ */
@ -1048,7 +845,7 @@ class UpdateExecutor
'create_backup' => $createBackup, 'create_backup' => $createBackup,
'started_at' => (new \DateTime())->format('c'), 'started_at' => (new \DateTime())->format('c'),
'current_step' => 0, 'current_step' => 0,
'total_steps' => 12, 'total_steps' => 14,
'step_name' => 'initializing', 'step_name' => 'initializing',
'step_message' => 'Starting update process...', 'step_message' => 'Starting update process...',
'steps' => [], 'steps' => [],

View file

@ -34,6 +34,23 @@
</div> </div>
{% endif %} {% endif %}
{# Web Updates Disabled Warning #}
{% if web_updates_disabled %}
<div class="alert alert-secondary" role="alert">
<i class="fas fa-ban me-2"></i>
<strong>{% trans %}update_manager.web_updates_disabled{% endtrans %}</strong>
<p class="mb-0 mt-2">{% trans %}update_manager.web_updates_disabled_hint{% endtrans %}</p>
</div>
{% endif %}
{# Backup Restore Disabled Warning #}
{% if backup_restore_disabled %}
<div class="alert alert-secondary" role="alert">
<i class="fas fa-ban me-2"></i>
<strong>{% trans %}update_manager.backup_restore_disabled{% endtrans %}</strong>
</div>
{% endif %}
<div class="row"> <div class="row">
{# Current Version Card #} {# Current Version Card #}
<div class="col-lg-6 mb-4"> <div class="col-lg-6 mb-4">
@ -132,7 +149,7 @@
{% endif %} {% endif %}
</div> </div>
{% 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 %}
<form action="{{ path('admin_update_manager_start') }}" method="post" <form action="{{ path('admin_update_manager_start') }}" method="post"
data-controller="update-confirm" data-controller="update-confirm"
data-update-confirm-is-downgrade-value="false" data-update-confirm-is-downgrade-value="false"
@ -241,7 +258,7 @@
title="{% trans %}update_manager.view_release_notes{% endtrans %}"> title="{% trans %}update_manager.view_release_notes{% endtrans %}">
<i class="fas fa-file-alt"></i> <i class="fas fa-file-alt"></i>
</a> </a>
{% 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 %}
<form action="{{ path('admin_update_manager_start') }}" method="post" class="d-inline" <form action="{{ path('admin_update_manager_start') }}" method="post" class="d-inline"
data-controller="update-confirm" data-controller="update-confirm"
data-update-confirm-is-downgrade-value="{{ release.version < status.current_version ? 'true' : 'false' }}" data-update-confirm-is-downgrade-value="{{ release.version < status.current_version ? 'true' : 'false' }}"
@ -356,7 +373,7 @@
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB {{ (backup.size / 1024 / 1024)|number_format(1) }} MB
</td> </td>
<td class="text-end"> <td class="text-end">
{% if status.can_auto_update and validation.valid %} {% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline" <form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
data-controller="backup-restore" data-controller="backup-restore"
data-backup-restore-filename-value="{{ backup.file }}" data-backup-restore-filename-value="{{ backup.file }}"

View file

@ -15018,6 +15018,24 @@ Buerklin-API Authentication server:
<target>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.</target> <target>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.</target>
</segment> </segment>
</unit> </unit>
<unit id="um_web_updates_disabled" name="update_manager.web_updates_disabled">
<segment state="translated">
<source>update_manager.web_updates_disabled</source>
<target>Web-based updates are disabled</target>
</segment>
</unit>
<unit id="um_web_updates_disabled_hint" name="update_manager.web_updates_disabled_hint">
<segment state="translated">
<source>update_manager.web_updates_disabled_hint</source>
<target>Web-based updates have been disabled by the server administrator. Please use the CLI command "php bin/console partdb:update" to perform updates.</target>
</segment>
</unit>
<unit id="um_backup_restore_disabled" name="update_manager.backup_restore_disabled">
<segment state="translated">
<source>update_manager.backup_restore_disabled</source>
<target>Backup restore is disabled by server configuration.</target>
</segment>
</unit>
<unit id="kHKChQB" name="settings.ips.conrad"> <unit id="kHKChQB" name="settings.ips.conrad">
<segment> <segment>
<source>settings.ips.conrad</source> <source>settings.ips.conrad</source>