mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-09 16:59:37 +00:00
* Add manual backup creation and delete buttons to Update Manager - Add "Create Backup" button in the backups tab for on-demand backups - Add delete buttons (trash icons) for update logs and backups - New controller routes with CSRF protection and permission checks - Use data-turbo-confirm for CSP-safe confirmation dialogs - Add deleteLog() method to UpdateExecutor with filename validation * Add Docker backup support: download button, SQLite restore fix, decouple from auto-update - Decouple backup creation/restore UI from can_auto_update so Docker and other non-git installations can use backup features - Add backup download endpoint for saving backups externally - Fix SQLite restore to use configured DATABASE_URL path instead of hardcoded var/app.db (affects Docker and custom SQLite paths) - Show Docker-specific warning about var/backups/ not being persisted - Pass is_docker flag to template via InstallationTypeDetector * Add tests for backup/update manager improvements - Controller tests: auth, CSRF validation, 404 for missing backups, restore disabled check - UpdateExecutor: deleteLog validation, non-existent file, successful deletion - BackupManager: deleteBackup validation for missing/non-zip files * Fix test failures: add locale prefix to URLs, correct log directory path * Fix auth test: expect 401 instead of redirect for HTTP Basic auth * Improve test coverage for update manager controller Add happy-path tests for backup creation, deletion, download, and log deletion with valid CSRF tokens. Also test the locked state blocking backup creation. * Fix CSRF tests: initialize session before getting tokens * Fix CSRF tests: extract tokens from rendered page HTML * Harden backup security: password confirmation, CSRF, env toggle Address security review feedback from jbtronics: - Add IS_AUTHENTICATED_FULLY to all sensitive endpoints (create/delete backup, delete log, download backup, start update, restore) - Change backup download from GET to POST with CSRF token - Require password confirmation before downloading backups (backups contain sensitive data like password hashes and secrets) - Add DISABLE_BACKUP_DOWNLOAD env var (default: disabled) to control whether backup downloads are allowed - Add password confirmation modal with security warning in template - Add comprehensive tests: auth checks, env var blocking, POST-only enforcement, status/progress endpoint auth * Fix download modal: use per-backup modals for CSP/Turbo compatibility - Replace shared modal + inline JS with per-backup modals that have filename pre-set in hidden fields (no JavaScript needed) - Add data-turbo="false" to download forms for native browser handling - Add data-bs-dismiss="modal" to submit button to auto-close modal - Add hidden username field for Chrome accessibility best practice - Fix test: GET on POST-only route returns 404 not 405 * Fixed translation keys * Fixed text justification in download modal * Hardenened security of deleteLogEndpoint * Show whether backup, restores and updates are allowed or disabled by sysadmin on update manager * Added documentation for update manager related env variables --------- Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
1056 lines
38 KiB
PHP
1056 lines
38 KiB
PHP
<?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 Psr\Log\LoggerInterface;
|
|
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Symfony\Component\Filesystem\Filesystem;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
/**
|
|
* Handles the execution of Part-DB updates with safety mechanisms.
|
|
*
|
|
* This service should primarily be used from CLI commands, not web requests,
|
|
* due to the long-running nature of updates and permission requirements.
|
|
*
|
|
* For web requests, use startBackgroundUpdate() method.
|
|
*/
|
|
class UpdateExecutor
|
|
{
|
|
private const LOCK_FILE = 'var/update.lock';
|
|
private const MAINTENANCE_FILE = 'var/maintenance.flag';
|
|
private const UPDATE_LOG_DIR = 'var/log/updates';
|
|
private const PROGRESS_FILE = 'var/update_progress.json';
|
|
|
|
/** @var array<array{step: string, message: string, success: bool, timestamp: string, duration: ?float}> */
|
|
private array $steps = [];
|
|
|
|
private ?string $currentLogFile = null;
|
|
|
|
public function __construct(
|
|
#[Autowire(param: 'kernel.project_dir')]
|
|
private readonly string $project_dir,
|
|
private readonly LoggerInterface $logger,
|
|
private readonly Filesystem $filesystem,
|
|
private readonly InstallationTypeDetector $installationTypeDetector,
|
|
private readonly UpdateChecker $updateChecker,
|
|
private readonly BackupManager $backupManager,
|
|
private readonly CommandRunHelper $commandRunHelper,
|
|
#[Autowire(param: 'app.debug_mode')]
|
|
private readonly bool $debugMode = false,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Get the current version string for use in filenames.
|
|
*/
|
|
private function getCurrentVersionString(): string
|
|
{
|
|
return $this->updateChecker->getCurrentVersionString();
|
|
}
|
|
|
|
/**
|
|
* Check if an update is currently in progress.
|
|
*/
|
|
public function isLocked(): bool
|
|
{
|
|
// Check if lock is stale (older than 1 hour)
|
|
$lockData = $this->getLockInfo();
|
|
if ($lockData === null) {
|
|
return false;
|
|
}
|
|
|
|
if ($lockData && isset($lockData['started_at'])) {
|
|
$startedAt = new \DateTime($lockData['started_at']);
|
|
$now = new \DateTime();
|
|
$diff = $now->getTimestamp() - $startedAt->getTimestamp();
|
|
|
|
// If lock is older than 1 hour, consider it stale
|
|
if ($diff > 3600) {
|
|
$this->logger->warning('Found stale update lock, removing it');
|
|
$this->releaseLock();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get lock information, or null if not locked.
|
|
* @return null|array{started_at: string, pid: int, user: string}
|
|
*/
|
|
public function getLockInfo(): ?array
|
|
{
|
|
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
|
|
|
if (!file_exists($lockFile)) {
|
|
return null;
|
|
}
|
|
|
|
return json_decode(file_get_contents($lockFile), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/**
|
|
* Check if maintenance mode is enabled.
|
|
*/
|
|
public function isMaintenanceMode(): bool
|
|
{
|
|
return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE);
|
|
}
|
|
|
|
/**
|
|
* Get maintenance mode information.
|
|
* @return null|array{enabled_at: string, reason: string}
|
|
*/
|
|
public function getMaintenanceInfo(): ?array
|
|
{
|
|
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
|
|
|
if (!file_exists($maintenanceFile)) {
|
|
return null;
|
|
}
|
|
|
|
return json_decode(file_get_contents($maintenanceFile), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/**
|
|
* Acquire an exclusive lock for the update process.
|
|
*/
|
|
public function acquireLock(): bool
|
|
{
|
|
if ($this->isLocked()) {
|
|
return false;
|
|
}
|
|
|
|
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
|
$lockDir = dirname($lockFile);
|
|
|
|
if (!is_dir($lockDir)) {
|
|
$this->filesystem->mkdir($lockDir);
|
|
}
|
|
|
|
$lockData = [
|
|
'started_at' => (new \DateTime())->format('c'),
|
|
'pid' => getmypid(),
|
|
'user' => get_current_user(),
|
|
];
|
|
|
|
$this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Release the update lock.
|
|
*/
|
|
public function releaseLock(): void
|
|
{
|
|
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
|
|
|
if (file_exists($lockFile)) {
|
|
$this->filesystem->remove($lockFile);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable maintenance mode to block user access during update.
|
|
*/
|
|
public function enableMaintenanceMode(string $reason = 'Update in progress'): void
|
|
{
|
|
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
|
$maintenanceDir = dirname($maintenanceFile);
|
|
|
|
if (!is_dir($maintenanceDir)) {
|
|
$this->filesystem->mkdir($maintenanceDir);
|
|
}
|
|
|
|
$data = [
|
|
'enabled_at' => (new \DateTime())->format('c'),
|
|
'reason' => $reason,
|
|
];
|
|
|
|
$this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
|
}
|
|
|
|
/**
|
|
* Disable maintenance mode.
|
|
*/
|
|
public function disableMaintenanceMode(): void
|
|
{
|
|
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
|
|
|
if (file_exists($maintenanceFile)) {
|
|
$this->filesystem->remove($maintenanceFile);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset PHP OPcache for the web server process.
|
|
*
|
|
* OPcache in PHP-FPM is separate from CLI. After updating code files,
|
|
* PHP-FPM may still serve stale cached bytecode, causing constructor
|
|
* mismatches and 500 errors. This method creates a temporary PHP script
|
|
* in the public directory, invokes it via HTTP to reset OPcache in the
|
|
* web server context, then removes the script.
|
|
*
|
|
* @return bool Whether OPcache was successfully reset
|
|
*/
|
|
private function resetOpcache(): bool
|
|
{
|
|
$token = bin2hex(random_bytes(16));
|
|
$resetScript = $this->project_dir . '/public/_opcache_reset_' . $token . '.php';
|
|
|
|
try {
|
|
// Create a temporary PHP script that resets OPcache
|
|
$scriptContent = '<?php '
|
|
. 'if (function_exists("opcache_reset")) { opcache_reset(); echo "OK"; } '
|
|
. 'else { echo "NO_OPCACHE"; } '
|
|
. '@unlink(__FILE__);';
|
|
|
|
$this->filesystem->dumpFile($resetScript, $scriptContent);
|
|
|
|
// Try to invoke it via HTTP on localhost
|
|
$urls = [
|
|
'http://127.0.0.1/_opcache_reset_' . $token . '.php',
|
|
'http://localhost/_opcache_reset_' . $token . '.php',
|
|
];
|
|
|
|
$success = false;
|
|
foreach ($urls as $url) {
|
|
try {
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'timeout' => 5,
|
|
'ignore_errors' => true,
|
|
],
|
|
]);
|
|
|
|
$response = @file_get_contents($url, false, $context);
|
|
if ($response === 'OK') {
|
|
$this->logger->info('OPcache reset via ' . $url);
|
|
$success = true;
|
|
break;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Try next URL
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!$success) {
|
|
$this->logger->info('OPcache reset via HTTP not available, trying CLI fallback');
|
|
// CLI opcache_reset() only affects CLI, but try anyway
|
|
if (function_exists('opcache_reset')) {
|
|
opcache_reset();
|
|
}
|
|
}
|
|
|
|
return $success;
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('OPcache reset failed: ' . $e->getMessage());
|
|
return false;
|
|
} finally {
|
|
// Ensure the temp script is removed
|
|
if (file_exists($resetScript)) {
|
|
@unlink($resetScript);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate that we can perform an update.
|
|
*
|
|
* @return array{valid: bool, errors: array<string>}
|
|
*/
|
|
public function validateUpdatePreconditions(): array
|
|
{
|
|
$errors = [];
|
|
|
|
// Check installation type
|
|
$installType = $this->installationTypeDetector->detect();
|
|
if (!$installType->supportsAutoUpdate()) {
|
|
$errors[] = sprintf(
|
|
'Installation type "%s" does not support automatic updates. %s',
|
|
$installType->getLabel(),
|
|
$installType->getUpdateInstructions()
|
|
);
|
|
}
|
|
|
|
// Check for Git installation
|
|
if ($installType === InstallationType::GIT) {
|
|
// Check if git is available
|
|
$process = new Process(['git', '--version']);
|
|
$process->run();
|
|
if (!$process->isSuccessful()) {
|
|
$errors[] = 'Git command not found. Please ensure Git is installed and in PATH.';
|
|
}
|
|
|
|
// Check for local changes
|
|
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
|
$process->run();
|
|
if (!empty(trim($process->getOutput()))) {
|
|
$errors[] = 'There are uncommitted local changes. Please commit or stash them before updating.';
|
|
}
|
|
}
|
|
|
|
// Check if composer is available
|
|
$process = new Process(['composer', '--version']);
|
|
$process->run();
|
|
if (!$process->isSuccessful()) {
|
|
$errors[] = 'Composer command not found. Please ensure Composer is installed and in PATH.';
|
|
}
|
|
|
|
// Check if PHP CLI is available
|
|
$process = new Process(['php', '--version']);
|
|
$process->run();
|
|
if (!$process->isSuccessful()) {
|
|
$errors[] = 'PHP CLI not found. Please ensure PHP is installed and in PATH.';
|
|
}
|
|
|
|
// Check if yarn is available (for frontend assets)
|
|
$process = new Process(['yarn', '--version']);
|
|
$process->run();
|
|
if (!$process->isSuccessful()) {
|
|
$errors[] = 'Yarn command not found. Please ensure Yarn is installed and in PATH for frontend asset compilation.';
|
|
}
|
|
|
|
// Check write permissions
|
|
$testDirs = ['var', 'vendor', 'public'];
|
|
foreach ($testDirs as $dir) {
|
|
$fullPath = $this->project_dir . '/' . $dir;
|
|
if (is_dir($fullPath) && !is_writable($fullPath)) {
|
|
$errors[] = sprintf('Directory "%s" is not writable.', $dir);
|
|
}
|
|
}
|
|
|
|
// Check if already locked
|
|
if ($this->isLocked()) {
|
|
$lockInfo = $this->getLockInfo();
|
|
$errors[] = sprintf(
|
|
'An update is already in progress (started at %s).',
|
|
$lockInfo['started_at'] ?? 'unknown time'
|
|
);
|
|
}
|
|
|
|
return [
|
|
'valid' => empty($errors),
|
|
'errors' => $errors,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Execute the update to a specific version.
|
|
*
|
|
* @param string $targetVersion The target version/tag to update to (e.g., "v2.6.0")
|
|
* @param bool $createBackup Whether to create a backup before updating
|
|
* @param callable|null $onProgress Callback for progress updates
|
|
*
|
|
* @return array{success: bool, steps: array, rollback_tag: ?string, error: ?string, log_file: ?string}
|
|
*/
|
|
public function executeUpdate(
|
|
string $targetVersion,
|
|
bool $createBackup = true,
|
|
?callable $onProgress = null
|
|
): array {
|
|
$this->steps = [];
|
|
$rollbackTag = null;
|
|
$startTime = microtime(true);
|
|
|
|
// Initialize log file
|
|
$this->initializeLogFile($targetVersion);
|
|
|
|
$log = function (string $step, string $message, bool $success = true, ?float $duration = null) use ($onProgress): void {
|
|
$entry = [
|
|
'step' => $step,
|
|
'message' => $message,
|
|
'success' => $success,
|
|
'timestamp' => (new \DateTime())->format('c'),
|
|
'duration' => $duration,
|
|
];
|
|
|
|
$this->steps[] = $entry;
|
|
$this->writeToLogFile($entry);
|
|
$this->logger->info("Update [{$step}]: {$message}", ['success' => $success]);
|
|
|
|
if ($onProgress) {
|
|
$onProgress($entry);
|
|
}
|
|
};
|
|
|
|
try {
|
|
// Validate preconditions
|
|
$validation = $this->validateUpdatePreconditions();
|
|
if (!$validation['valid']) {
|
|
throw new \RuntimeException('Precondition check failed: ' . implode('; ', $validation['errors']));
|
|
}
|
|
|
|
// Step 1: Acquire lock
|
|
$stepStart = microtime(true);
|
|
if (!$this->acquireLock()) {
|
|
throw new \RuntimeException('Could not acquire update lock. Another update may be in progress.');
|
|
}
|
|
$log('lock', 'Acquired exclusive update lock', true, microtime(true) - $stepStart);
|
|
|
|
// Step 2: Enable maintenance mode
|
|
$stepStart = microtime(true);
|
|
$this->enableMaintenanceMode('Updating to ' . $targetVersion);
|
|
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
|
|
|
|
// Step 3: Create rollback point with version info
|
|
$stepStart = microtime(true);
|
|
$currentVersion = $this->getCurrentVersionString();
|
|
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
|
$rollbackTag = 'pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His');
|
|
$this->runCommand(['git', 'tag', $rollbackTag], 'Create rollback tag');
|
|
$log('rollback_tag', 'Created rollback tag: ' . $rollbackTag, true, microtime(true) - $stepStart);
|
|
|
|
// Step 4: Create backup (optional)
|
|
if ($createBackup) {
|
|
$stepStart = microtime(true);
|
|
$backupFile = $this->backupManager->createBackup($targetVersion);
|
|
$log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart);
|
|
}
|
|
|
|
// Step 5: Fetch from remote
|
|
$stepStart = microtime(true);
|
|
$this->runCommand(['git', 'fetch', '--tags', '--force', 'origin'], 'Fetch from origin', 120);
|
|
$log('fetch', 'Fetched latest changes and tags from origin', true, microtime(true) - $stepStart);
|
|
|
|
// Step 6: Checkout target version
|
|
$stepStart = microtime(true);
|
|
$this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version');
|
|
$log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart);
|
|
|
|
// Step 7: Install PHP dependencies
|
|
$stepStart = microtime(true);
|
|
if ($this->debugMode) {
|
|
$this->runCommand([ // Install with dev dependencies in debug mode
|
|
'composer',
|
|
'install',
|
|
'--no-interaction',
|
|
'--no-progress',
|
|
], 'Install PHP dependencies', 600);
|
|
} else {
|
|
$this->runCommand([
|
|
'composer',
|
|
'install',
|
|
'--no-dev',
|
|
'--optimize-autoloader',
|
|
'--no-interaction',
|
|
'--no-progress',
|
|
], 'Install PHP dependencies', 600);
|
|
}
|
|
$log('composer', 'Installed/updated PHP dependencies', true, microtime(true) - $stepStart);
|
|
|
|
// Step 8: Install frontend dependencies
|
|
$stepStart = microtime(true);
|
|
$this->runCommand([
|
|
'yarn', 'install',
|
|
'--frozen-lockfile',
|
|
'--non-interactive',
|
|
], 'Install frontend dependencies', 600);
|
|
$log('yarn_install', 'Installed frontend dependencies', true, microtime(true) - $stepStart);
|
|
|
|
// Step 9: Build frontend assets
|
|
$stepStart = microtime(true);
|
|
$this->runCommand([
|
|
'yarn', 'build',
|
|
], 'Build frontend assets', 600);
|
|
$log('yarn_build', 'Built frontend assets', true, microtime(true) - $stepStart);
|
|
|
|
// Step 10: Run database migrations
|
|
$stepStart = microtime(true);
|
|
$this->runCommand([
|
|
'php', 'bin/console', 'doctrine:migrations:migrate',
|
|
'--no-interaction',
|
|
'--allow-no-migration',
|
|
], 'Run migrations', 300);
|
|
$log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart);
|
|
|
|
// Step 11: Clear cache
|
|
$stepStart = microtime(true);
|
|
$this->runCommand([
|
|
'php', 'bin/console', 'cache:pool:clear', '--all',
|
|
'--env=prod',
|
|
'--no-interaction',
|
|
], 'Clear cache', 120);
|
|
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
|
|
|
|
// Step 12: Warm up cache
|
|
$stepStart = microtime(true);
|
|
$this->runCommand([
|
|
'php', 'bin/console', 'cache:warmup',
|
|
'--env=prod',
|
|
], 'Warmup cache', 120);
|
|
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
|
|
|
// Step 13: Reset OPcache (if available)
|
|
$stepStart = microtime(true);
|
|
$opcacheResult = $this->resetOpcache();
|
|
$log('opcache_reset', $opcacheResult
|
|
? 'Reset PHP OPcache for web server'
|
|
: 'OPcache reset skipped (not available or not needed)',
|
|
true, microtime(true) - $stepStart);
|
|
|
|
// Step 14: Disable maintenance mode
|
|
$stepStart = microtime(true);
|
|
$this->disableMaintenanceMode();
|
|
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
|
|
|
// Step 15: Release lock
|
|
$stepStart = microtime(true);
|
|
$this->releaseLock();
|
|
|
|
$totalDuration = microtime(true) - $startTime;
|
|
$log('complete', sprintf('Update completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart);
|
|
|
|
return [
|
|
'success' => true,
|
|
'steps' => $this->steps,
|
|
'rollback_tag' => $rollbackTag,
|
|
'error' => null,
|
|
'log_file' => $this->currentLogFile,
|
|
'duration' => $totalDuration,
|
|
];
|
|
|
|
} catch (\Exception $e) {
|
|
$log('error', 'Update failed: ' . $e->getMessage(), false);
|
|
|
|
// Attempt rollback
|
|
if ($rollbackTag) {
|
|
try {
|
|
$this->runCommand(['git', 'checkout', $rollbackTag], 'Rollback');
|
|
$log('rollback', 'Rolled back to: ' . $rollbackTag, true);
|
|
|
|
// Re-run composer install after rollback
|
|
$this->runCommand([
|
|
'composer', 'install',
|
|
'--no-dev',
|
|
'--optimize-autoloader',
|
|
'--no-interaction',
|
|
], 'Reinstall dependencies after rollback', 600);
|
|
$log('rollback_composer', 'Reinstalled PHP dependencies after rollback', true);
|
|
|
|
// Re-run yarn install after rollback
|
|
$this->runCommand([
|
|
'yarn', 'install',
|
|
'--frozen-lockfile',
|
|
'--non-interactive',
|
|
], 'Reinstall frontend dependencies after rollback', 600);
|
|
$log('rollback_yarn_install', 'Reinstalled frontend dependencies after rollback', true);
|
|
|
|
// Re-run yarn build after rollback
|
|
$this->runCommand([
|
|
'yarn', 'build',
|
|
], 'Rebuild frontend assets after rollback', 600);
|
|
$log('rollback_yarn_build', 'Rebuilt frontend assets after rollback', true);
|
|
|
|
// Clear cache after rollback
|
|
$this->runCommand([
|
|
'php', 'bin/console', 'cache:pool:clear', '--all',
|
|
'--env=prod',
|
|
], 'Clear cache after rollback', 120);
|
|
$log('rollback_cache', 'Cleared cache after rollback', true);
|
|
|
|
// Reset OPcache after rollback
|
|
$this->resetOpcache();
|
|
|
|
} catch (\Exception $rollbackError) {
|
|
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
|
|
}
|
|
}
|
|
|
|
// Clean up
|
|
$this->disableMaintenanceMode();
|
|
$this->releaseLock();
|
|
|
|
return [
|
|
'success' => false,
|
|
'steps' => $this->steps,
|
|
'rollback_tag' => $rollbackTag,
|
|
'error' => $e->getMessage(),
|
|
'log_file' => $this->currentLogFile,
|
|
'duration' => microtime(true) - $startTime,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a shell command with proper error handling.
|
|
*/
|
|
private function runCommand(array $command, string $description, int $timeout = 120): string
|
|
{
|
|
return $this->commandRunHelper->runCommand($command, $description, $timeout);
|
|
}
|
|
|
|
/**
|
|
* Initialize the log file for this update.
|
|
*/
|
|
private function initializeLogFile(string $targetVersion): void
|
|
{
|
|
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
|
|
|
if (!is_dir($logDir)) {
|
|
$this->filesystem->mkdir($logDir, 0755);
|
|
}
|
|
|
|
// Include version numbers in log filename: update-v2.5.1-to-v2.6.0-2024-01-30-185400.log
|
|
$currentVersion = $this->getCurrentVersionString();
|
|
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
|
$this->currentLogFile = $logDir . '/update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.log';
|
|
|
|
$header = sprintf(
|
|
"Part-DB Update Log\n" .
|
|
"==================\n" .
|
|
"Started: %s\n" .
|
|
"From Version: %s\n" .
|
|
"Target Version: %s\n" .
|
|
"==================\n\n",
|
|
date('Y-m-d H:i:s'),
|
|
$currentVersion,
|
|
$targetVersion
|
|
);
|
|
|
|
file_put_contents($this->currentLogFile, $header);
|
|
}
|
|
|
|
/**
|
|
* Write an entry to the log file.
|
|
*/
|
|
private function writeToLogFile(array $entry): void
|
|
{
|
|
if (!$this->currentLogFile) {
|
|
return;
|
|
}
|
|
|
|
$line = sprintf(
|
|
"[%s] %s: %s%s\n",
|
|
$entry['timestamp'],
|
|
strtoupper($entry['step']),
|
|
$entry['message'],
|
|
$entry['duration'] ? sprintf(' (%.2fs)', $entry['duration']) : ''
|
|
);
|
|
|
|
file_put_contents($this->currentLogFile, $line, FILE_APPEND);
|
|
}
|
|
|
|
/**
|
|
* Get list of update log files.
|
|
* @return array{file: string, path: string, date: int, size: int}[]
|
|
*/
|
|
public function getUpdateLogs(): array
|
|
{
|
|
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
|
|
|
if (!is_dir($logDir)) {
|
|
return [];
|
|
}
|
|
|
|
$logs = [];
|
|
foreach (glob($logDir . '/update-*.log') as $logFile) {
|
|
$logs[] = [
|
|
'file' => basename($logFile),
|
|
'path' => $logFile,
|
|
'date' => filemtime($logFile),
|
|
'size' => filesize($logFile),
|
|
];
|
|
}
|
|
|
|
// Sort by date descending
|
|
usort($logs, static fn($a, $b) => $b['date'] <=> $a['date']);
|
|
|
|
return $logs;
|
|
}
|
|
|
|
|
|
/**
|
|
* Delete a specific update log file.
|
|
*/
|
|
public function deleteLog(string $filename): bool
|
|
{
|
|
// Validate filename pattern for security
|
|
if (!preg_match('/^update-[\w.\-]+\.log$/', $filename)) {
|
|
$this->logger->warning('Attempted to delete invalid log filename: ' . $filename);
|
|
return false;
|
|
}
|
|
|
|
$logPath = $this->project_dir . '/' . self::UPDATE_LOG_DIR . '/' . basename($filename);
|
|
|
|
if (!file_exists($logPath)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$this->filesystem->remove($logPath);
|
|
$this->logger->info('Deleted update log: ' . $filename);
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Failed to delete update log: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore from a backup file with maintenance mode and cache clearing.
|
|
*
|
|
* This wraps BackupManager::restoreBackup with additional safety measures
|
|
* like lock acquisition, maintenance mode, and cache operations.
|
|
*
|
|
* @param string $filename The backup filename to restore
|
|
* @param bool $restoreDatabase Whether to restore the database
|
|
* @param bool $restoreConfig Whether to restore config files
|
|
* @param bool $restoreAttachments Whether to restore attachments
|
|
* @param callable|null $onProgress Callback for progress updates
|
|
* @return array{success: bool, steps: array, error: ?string}
|
|
*/
|
|
public function restoreBackup(
|
|
string $filename,
|
|
bool $restoreDatabase = true,
|
|
bool $restoreConfig = false,
|
|
bool $restoreAttachments = false,
|
|
?callable $onProgress = null
|
|
): array {
|
|
$this->steps = [];
|
|
$startTime = microtime(true);
|
|
|
|
$log = function (string $step, string $message, bool $success, ?float $duration = null) use ($onProgress): void {
|
|
$entry = [
|
|
'step' => $step,
|
|
'message' => $message,
|
|
'success' => $success,
|
|
'timestamp' => (new \DateTime())->format('c'),
|
|
'duration' => $duration,
|
|
];
|
|
$this->steps[] = $entry;
|
|
$this->logger->info('[Restore] ' . $step . ': ' . $message, ['success' => $success]);
|
|
|
|
if ($onProgress) {
|
|
$onProgress($entry);
|
|
}
|
|
};
|
|
|
|
try {
|
|
$stepStart = microtime(true);
|
|
|
|
// Step 1: Acquire lock
|
|
if (!$this->acquireLock()) {
|
|
throw new \RuntimeException('Could not acquire lock. Another operation may be in progress.');
|
|
}
|
|
$log('lock', 'Acquired exclusive restore lock', true, microtime(true) - $stepStart);
|
|
|
|
// Step 2: Enable maintenance mode
|
|
$stepStart = microtime(true);
|
|
$this->enableMaintenanceMode('Restoring from backup...');
|
|
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
|
|
|
|
// Step 3: Delegate to BackupManager for core restoration
|
|
$stepStart = microtime(true);
|
|
$result = $this->backupManager->restoreBackup(
|
|
$filename,
|
|
$restoreDatabase,
|
|
$restoreConfig,
|
|
$restoreAttachments,
|
|
function ($entry) use ($log) {
|
|
// Forward progress from BackupManager
|
|
$log($entry['step'], $entry['message'], $entry['success'], $entry['duration'] ?? null);
|
|
}
|
|
);
|
|
|
|
if (!$result['success']) {
|
|
throw new \RuntimeException($result['error'] ?? 'Restore failed');
|
|
}
|
|
|
|
// Step 4: Clear cache
|
|
$stepStart = microtime(true);
|
|
$this->runCommand(['php', 'bin/console', 'cache:clear', '--no-warmup'], 'Clear cache');
|
|
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
|
|
|
|
// Step 5: Warm up cache
|
|
$stepStart = microtime(true);
|
|
$this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache');
|
|
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
|
|
|
// Step 6: Reset OPcache
|
|
$stepStart = microtime(true);
|
|
$this->resetOpcache();
|
|
$log('opcache_reset', 'Reset PHP OPcache', true, microtime(true) - $stepStart);
|
|
|
|
// Step 7: Disable maintenance mode
|
|
$stepStart = microtime(true);
|
|
$this->disableMaintenanceMode();
|
|
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
|
|
|
// Step 8: Release lock
|
|
$this->releaseLock();
|
|
|
|
$totalDuration = microtime(true) - $startTime;
|
|
$log('complete', sprintf('Restore completed successfully in %.1f seconds', $totalDuration), true);
|
|
|
|
return [
|
|
'success' => true,
|
|
'steps' => $this->steps,
|
|
'error' => null,
|
|
];
|
|
|
|
} catch (\Throwable $e) {
|
|
$this->logger->error('Restore failed: ' . $e->getMessage(), [
|
|
'exception' => $e,
|
|
'file' => $filename,
|
|
]);
|
|
|
|
// Try to clean up
|
|
try {
|
|
$this->disableMaintenanceMode();
|
|
$this->releaseLock();
|
|
} catch (\Throwable $cleanupError) {
|
|
$this->logger->error('Cleanup after failed restore also failed', ['error' => $cleanupError->getMessage()]);
|
|
}
|
|
|
|
return [
|
|
'success' => false,
|
|
'steps' => $this->steps,
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the path to the progress file.
|
|
*/
|
|
public function getProgressFilePath(): string
|
|
{
|
|
return $this->project_dir . '/' . self::PROGRESS_FILE;
|
|
}
|
|
|
|
/**
|
|
* Save progress to file for web UI polling.
|
|
* @param array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string} $progress
|
|
*/
|
|
private function saveProgress(array $progress): void
|
|
{
|
|
$progressFile = $this->getProgressFilePath();
|
|
$progressDir = dirname($progressFile);
|
|
|
|
if (!is_dir($progressDir)) {
|
|
$this->filesystem->mkdir($progressDir);
|
|
}
|
|
|
|
$this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
|
|
}
|
|
|
|
/**
|
|
* Get current update progress from file.
|
|
* @return null|array{status: string, target_version: string, create_backup: bool, started_at: string, current_step: int, total_steps: int, step_name: string, step_message: string, steps: array, error: ?string}
|
|
*/
|
|
public function getProgress(): ?array
|
|
{
|
|
$progressFile = $this->getProgressFilePath();
|
|
|
|
if (!file_exists($progressFile)) {
|
|
return null;
|
|
}
|
|
|
|
$data = json_decode(file_get_contents($progressFile), true, 512, JSON_THROW_ON_ERROR);
|
|
|
|
// If the progress file is stale (older than 30 minutes), consider it invalid
|
|
if ($data && isset($data['started_at'])) {
|
|
$startedAt = strtotime($data['started_at']);
|
|
if (time() - $startedAt > 1800) {
|
|
$this->clearProgress();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Clear progress file.
|
|
*/
|
|
public function clearProgress(): void
|
|
{
|
|
$progressFile = $this->getProgressFilePath();
|
|
|
|
if (file_exists($progressFile)) {
|
|
$this->filesystem->remove($progressFile);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an update is currently running (based on progress file).
|
|
*/
|
|
public function isUpdateRunning(): bool
|
|
{
|
|
$progress = $this->getProgress();
|
|
|
|
if (!$progress) {
|
|
return false;
|
|
}
|
|
|
|
return isset($progress['status']) && $progress['status'] === 'running';
|
|
}
|
|
|
|
/**
|
|
* Start the update process in the background.
|
|
* Returns the process ID or null on failure.
|
|
*/
|
|
public function startBackgroundUpdate(string $targetVersion, bool $createBackup = true): ?int
|
|
{
|
|
// Validate first
|
|
$validation = $this->validateUpdatePreconditions();
|
|
if (!$validation['valid']) {
|
|
$this->logger->error('Update validation failed', ['errors' => $validation['errors']]);
|
|
return null;
|
|
}
|
|
|
|
// Initialize progress file
|
|
$this->saveProgress([
|
|
'status' => 'starting',
|
|
'target_version' => $targetVersion,
|
|
'create_backup' => $createBackup,
|
|
'started_at' => (new \DateTime())->format('c'),
|
|
'current_step' => 0,
|
|
'total_steps' => 15,
|
|
'step_name' => 'initializing',
|
|
'step_message' => 'Starting update process...',
|
|
'steps' => [],
|
|
'error' => null,
|
|
]);
|
|
|
|
// Build the command to run in background
|
|
// Use 'php' from PATH as PHP_BINARY might point to php-fpm
|
|
$consolePath = $this->project_dir . '/bin/console';
|
|
$logFile = $this->project_dir . '/var/log/update-background.log';
|
|
|
|
// Ensure log directory exists
|
|
$logDir = dirname($logFile);
|
|
if (!is_dir($logDir)) {
|
|
$this->filesystem->mkdir($logDir, 0755);
|
|
}
|
|
|
|
//If we are on Windows, we cannot use nohup
|
|
if (PHP_OS_FAMILY === 'Windows') {
|
|
$command = sprintf(
|
|
'start /B php %s partdb:update %s %s --force --no-interaction >> %s 2>&1',
|
|
escapeshellarg($consolePath),
|
|
escapeshellarg($targetVersion),
|
|
$createBackup ? '' : '--no-backup',
|
|
escapeshellarg($logFile)
|
|
);
|
|
} else { //Unix like platforms should be able to use nohup
|
|
// Use nohup to properly detach the process from the web request
|
|
// The process will continue running even after the PHP request ends
|
|
$command = sprintf(
|
|
'nohup php %s partdb:update %s %s --force --no-interaction >> %s 2>&1 &',
|
|
escapeshellarg($consolePath),
|
|
escapeshellarg($targetVersion),
|
|
$createBackup ? '' : '--no-backup',
|
|
escapeshellarg($logFile)
|
|
);
|
|
}
|
|
|
|
$this->logger->info('Starting background update', [
|
|
'command' => $command,
|
|
'target_version' => $targetVersion,
|
|
]);
|
|
|
|
// Execute in background using shell_exec for proper detachment
|
|
// shell_exec with & runs the command in background
|
|
|
|
//@php-ignore-next-line We really need to use shell_exec here
|
|
$output = shell_exec($command);
|
|
|
|
// Give it a moment to start
|
|
usleep(500000); // 500ms
|
|
|
|
// Check if progress file was updated (indicates process started)
|
|
$progress = $this->getProgress();
|
|
if ($progress && isset($progress['status'])) {
|
|
$this->logger->info('Background update started successfully');
|
|
return 1; // Return a non-null value to indicate success
|
|
}
|
|
|
|
$this->logger->error('Background update may not have started', ['output' => $output]);
|
|
return 1; // Still return success as the process might just be slow to start
|
|
}
|
|
|
|
/**
|
|
* Execute update with progress file updates for web UI.
|
|
* This is called by the CLI command and updates the progress file.
|
|
*/
|
|
public function executeUpdateWithProgress(
|
|
string $targetVersion,
|
|
bool $createBackup = true,
|
|
?callable $onProgress = null
|
|
): array {
|
|
$totalSteps = 13;
|
|
$currentStep = 0;
|
|
|
|
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {
|
|
$currentStep++;
|
|
$progress = $this->getProgress() ?? [
|
|
'status' => 'running',
|
|
'target_version' => $targetVersion,
|
|
'create_backup' => $createBackup,
|
|
'started_at' => (new \DateTime())->format('c'),
|
|
'steps' => [],
|
|
];
|
|
|
|
$progress['current_step'] = $currentStep;
|
|
$progress['total_steps'] = $totalSteps;
|
|
$progress['step_name'] = $stepName;
|
|
$progress['step_message'] = $message;
|
|
$progress['status'] = 'running';
|
|
$progress['steps'][] = [
|
|
'step' => $stepName,
|
|
'message' => $message,
|
|
'success' => $success,
|
|
'timestamp' => (new \DateTime())->format('c'),
|
|
];
|
|
|
|
$this->saveProgress($progress);
|
|
};
|
|
|
|
// Wrap the existing executeUpdate with progress tracking
|
|
$result = $this->executeUpdate($targetVersion, $createBackup, function ($entry) use ($updateProgress, $onProgress) {
|
|
$updateProgress($entry['step'], $entry['message'], $entry['success']);
|
|
|
|
if ($onProgress) {
|
|
$onProgress($entry);
|
|
}
|
|
});
|
|
|
|
// Update final status
|
|
$finalProgress = $this->getProgress() ?? [];
|
|
$finalProgress['status'] = $result['success'] ? 'completed' : 'failed';
|
|
$finalProgress['completed_at'] = (new \DateTime())->format('c');
|
|
$finalProgress['result'] = $result;
|
|
$finalProgress['error'] = $result['error'];
|
|
$this->saveProgress($finalProgress);
|
|
|
|
return $result;
|
|
}
|
|
}
|