mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-12 04:29:36 +00:00
Add Update Manager for automated Part-DB updates
This feature adds a comprehensive Update Manager similar to Mainsail's update system, allowing administrators to update Part-DB directly from the web interface. Features: - Web UI at /admin/update-manager showing current and available versions - Support for Git-based installations with automatic update execution - Maintenance mode during updates to prevent user access - Automatic database backup before updates - Git rollback points for recovery (tags created before each update) - Progress tracking with real-time status updates - Update history and log viewing - Downgrade support with appropriate UI messaging - CLI command `php bin/console partdb:update` for server-side updates New files: - UpdateManagerController: Handles all web UI routes - UpdateCommand: CLI command for running updates - UpdateExecutor: Core update execution logic with safety mechanisms - UpdateChecker: GitHub API integration for version checking - InstallationTypeDetector: Detects installation type (Git/Docker/ZIP) - MaintenanceModeSubscriber: Blocks user access during maintenance - UpdateExtension: Twig functions for update notifications UI improvements: - Update notification in navbar for admins when update available - Confirmation dialogs for update/downgrade actions - Downgrade-specific text throughout the interface - Progress page with auto-refresh
This commit is contained in:
parent
ae4c0786b2
commit
42fe781ef8
16 changed files with 4126 additions and 0 deletions
446
src/Command/UpdateCommand.php
Normal file
446
src/Command/UpdateCommand.php
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
<?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\Command;
|
||||
|
||||
use App\Services\System\InstallationType;
|
||||
use App\Services\System\UpdateChecker;
|
||||
use App\Services\System\UpdateExecutor;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'partdb:update', description: 'Check for and install Part-DB updates', aliases: ['app:update'])]
|
||||
class UpdateCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly UpdateChecker $updateChecker,
|
||||
private readonly UpdateExecutor $updateExecutor)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHelp(<<<'HELP'
|
||||
The <info>%command.name%</info> command checks for Part-DB updates and can install them.
|
||||
|
||||
<comment>Check for updates:</comment>
|
||||
<info>php %command.full_name% --check</info>
|
||||
|
||||
<comment>List available versions:</comment>
|
||||
<info>php %command.full_name% --list</info>
|
||||
|
||||
<comment>Update to the latest version:</comment>
|
||||
<info>php %command.full_name%</info>
|
||||
|
||||
<comment>Update to a specific version:</comment>
|
||||
<info>php %command.full_name% v2.6.0</info>
|
||||
|
||||
<comment>Update without creating a backup (faster but riskier):</comment>
|
||||
<info>php %command.full_name% --no-backup</info>
|
||||
|
||||
<comment>Non-interactive update for scripts:</comment>
|
||||
<info>php %command.full_name% --force</info>
|
||||
|
||||
<comment>View update logs:</comment>
|
||||
<info>php %command.full_name% --logs</info>
|
||||
HELP
|
||||
)
|
||||
->addArgument(
|
||||
'version',
|
||||
InputArgument::OPTIONAL,
|
||||
'Target version to update to (e.g., v2.6.0). If not specified, updates to the latest stable version.'
|
||||
)
|
||||
->addOption(
|
||||
'check',
|
||||
'c',
|
||||
InputOption::VALUE_NONE,
|
||||
'Only check for updates without installing'
|
||||
)
|
||||
->addOption(
|
||||
'list',
|
||||
'l',
|
||||
InputOption::VALUE_NONE,
|
||||
'List all available versions'
|
||||
)
|
||||
->addOption(
|
||||
'no-backup',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip creating a backup before updating (not recommended)'
|
||||
)
|
||||
->addOption(
|
||||
'force',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'Skip confirmation prompts'
|
||||
)
|
||||
->addOption(
|
||||
'include-prerelease',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Include pre-release versions'
|
||||
)
|
||||
->addOption(
|
||||
'logs',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Show recent update logs'
|
||||
)
|
||||
->addOption(
|
||||
'refresh',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Force refresh of cached version information'
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Handle --logs option
|
||||
if ($input->getOption('logs')) {
|
||||
return $this->showLogs($io);
|
||||
}
|
||||
|
||||
// Handle --refresh option
|
||||
if ($input->getOption('refresh')) {
|
||||
$io->text('Refreshing version information...');
|
||||
$this->updateChecker->refreshGitInfo();
|
||||
$io->success('Version cache cleared.');
|
||||
}
|
||||
|
||||
// Handle --list option
|
||||
if ($input->getOption('list')) {
|
||||
return $this->listVersions($io, $input->getOption('include-prerelease'));
|
||||
}
|
||||
|
||||
// Get update status
|
||||
$status = $this->updateChecker->getUpdateStatus();
|
||||
|
||||
// Display current status
|
||||
$io->title('Part-DB Update Manager');
|
||||
|
||||
$this->displayStatus($io, $status);
|
||||
|
||||
// Handle --check option
|
||||
if ($input->getOption('check')) {
|
||||
return $this->checkOnly($io, $status);
|
||||
}
|
||||
|
||||
// Validate we can update
|
||||
$validationResult = $this->validateUpdate($io, $status);
|
||||
if ($validationResult !== null) {
|
||||
return $validationResult;
|
||||
}
|
||||
|
||||
// Determine target version
|
||||
$targetVersion = $input->getArgument('version');
|
||||
$includePrerelease = $input->getOption('include-prerelease');
|
||||
|
||||
if (!$targetVersion) {
|
||||
$latest = $this->updateChecker->getLatestRelease($includePrerelease);
|
||||
if (!$latest) {
|
||||
$io->error('Could not determine the latest version. Please specify a version manually.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$targetVersion = $latest['tag'];
|
||||
}
|
||||
|
||||
// Validate target version
|
||||
if (!$this->updateChecker->isNewerVersion($targetVersion)) {
|
||||
$io->warning(sprintf(
|
||||
'Version %s is not newer than the current version %s.',
|
||||
$targetVersion,
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
if (!$io->confirm('Do you want to proceed anyway?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm update
|
||||
if (!$input->getOption('force')) {
|
||||
$io->section('Update Plan');
|
||||
|
||||
$io->listing([
|
||||
sprintf('Target version: <info>%s</info>', $targetVersion),
|
||||
$input->getOption('no-backup')
|
||||
? '<fg=yellow>Backup will be SKIPPED</>'
|
||||
: 'A full backup will be created before updating',
|
||||
'Maintenance mode will be enabled during update',
|
||||
'Database migrations will be run automatically',
|
||||
'Cache will be cleared and rebuilt',
|
||||
]);
|
||||
|
||||
$io->warning('The update process may take several minutes. Do not interrupt it.');
|
||||
|
||||
if (!$io->confirm('Do you want to proceed with the update?', false)) {
|
||||
$io->info('Update cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute update
|
||||
return $this->executeUpdate($io, $targetVersion, !$input->getOption('no-backup'));
|
||||
}
|
||||
|
||||
private function displayStatus(SymfonyStyle $io, array $status): void
|
||||
{
|
||||
$io->definitionList(
|
||||
['Current Version' => sprintf('<info>%s</info>', $status['current_version'])],
|
||||
['Latest Version' => $status['latest_version']
|
||||
? sprintf('<info>%s</info>', $status['latest_version'])
|
||||
: '<fg=yellow>Unknown</>'],
|
||||
['Installation Type' => $status['installation']['type_name']],
|
||||
['Git Branch' => $status['git']['branch'] ?? '<fg=gray>N/A</>'],
|
||||
['Git Commit' => $status['git']['commit'] ?? '<fg=gray>N/A</>'],
|
||||
['Local Changes' => $status['git']['has_local_changes']
|
||||
? '<fg=yellow>Yes (update blocked)</>'
|
||||
: '<fg=green>No</>'],
|
||||
['Commits Behind' => $status['git']['commits_behind'] > 0
|
||||
? sprintf('<fg=yellow>%d</>', $status['git']['commits_behind'])
|
||||
: '<fg=green>0</>'],
|
||||
['Update Available' => $status['update_available']
|
||||
? '<fg=green>Yes</>'
|
||||
: 'No'],
|
||||
['Can Auto-Update' => $status['can_auto_update']
|
||||
? '<fg=green>Yes</>'
|
||||
: '<fg=yellow>No</>'],
|
||||
);
|
||||
|
||||
if (!empty($status['update_blockers'])) {
|
||||
$io->warning('Update blockers: ' . implode(', ', $status['update_blockers']));
|
||||
}
|
||||
}
|
||||
|
||||
private function checkOnly(SymfonyStyle $io, array $status): int
|
||||
{
|
||||
if (!$status['check_enabled']) {
|
||||
$io->warning('Update checking is disabled in privacy settings.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($status['update_available']) {
|
||||
$io->success(sprintf(
|
||||
'A new version is available: %s (current: %s)',
|
||||
$status['latest_version'],
|
||||
$status['current_version']
|
||||
));
|
||||
|
||||
if ($status['release_url']) {
|
||||
$io->text(sprintf('Release notes: <href=%s>%s</>', $status['release_url'], $status['release_url']));
|
||||
}
|
||||
|
||||
if ($status['can_auto_update']) {
|
||||
$io->text('');
|
||||
$io->text('Run <info>php bin/console partdb:update</info> to update.');
|
||||
} else {
|
||||
$io->text('');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->success('You are running the latest version.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function validateUpdate(SymfonyStyle $io, array $status): ?int
|
||||
{
|
||||
// Check if update checking is enabled
|
||||
if (!$status['check_enabled']) {
|
||||
$io->error('Update checking is disabled in privacy settings. Enable it to use automatic updates.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Check installation type
|
||||
if (!$status['can_auto_update']) {
|
||||
$io->error('Automatic updates are not supported for this installation type.');
|
||||
$io->text($status['installation']['update_instructions']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Validate preconditions
|
||||
$validation = $this->updateExecutor->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$io->error('Cannot proceed with update:');
|
||||
$io->listing($validation['errors']);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executeUpdate(SymfonyStyle $io, string $targetVersion, bool $createBackup): int
|
||||
{
|
||||
$io->section('Executing Update');
|
||||
$io->text(sprintf('Updating to version: <info>%s</info>', $targetVersion));
|
||||
$io->text('');
|
||||
|
||||
$progressCallback = function (array $step) use ($io): void {
|
||||
$icon = $step['success'] ? '<fg=green>✓</>' : '<fg=red>✗</>';
|
||||
$duration = $step['duration'] ? sprintf(' <fg=gray>(%.1fs)</>', $step['duration']) : '';
|
||||
$io->text(sprintf(' %s <info>%s</info>: %s%s', $icon, $step['step'], $step['message'], $duration));
|
||||
};
|
||||
|
||||
// Use executeUpdateWithProgress to update the progress file for web UI
|
||||
$result = $this->updateExecutor->executeUpdateWithProgress($targetVersion, $createBackup, $progressCallback);
|
||||
|
||||
$io->text('');
|
||||
|
||||
if ($result['success']) {
|
||||
$io->success(sprintf(
|
||||
'Successfully updated to %s in %.1f seconds!',
|
||||
$targetVersion,
|
||||
$result['duration']
|
||||
));
|
||||
|
||||
$io->text([
|
||||
sprintf('Rollback tag: <info>%s</info>', $result['rollback_tag']),
|
||||
sprintf('Log file: <info>%s</info>', $result['log_file']),
|
||||
]);
|
||||
|
||||
$io->note('If you encounter any issues, you can rollback using: git checkout ' . $result['rollback_tag']);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->error('Update failed: ' . $result['error']);
|
||||
|
||||
if ($result['rollback_tag']) {
|
||||
$io->warning(sprintf('System was rolled back to: %s', $result['rollback_tag']));
|
||||
}
|
||||
|
||||
if ($result['log_file']) {
|
||||
$io->text(sprintf('See log file for details: %s', $result['log_file']));
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
private function listVersions(SymfonyStyle $io, bool $includePrerelease): int
|
||||
{
|
||||
$releases = $this->updateChecker->getAvailableReleases(15);
|
||||
$currentVersion = $this->updateChecker->getCurrentVersionString();
|
||||
|
||||
if (empty($releases)) {
|
||||
$io->warning('Could not fetch available versions. Check your internet connection.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title('Available Part-DB Versions');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Tag', 'Version', 'Released', 'Status']);
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $release['version'];
|
||||
$status = [];
|
||||
|
||||
if (version_compare($version, $currentVersion, '=')) {
|
||||
$status[] = '<fg=cyan>current</>';
|
||||
} elseif (version_compare($version, $currentVersion, '>')) {
|
||||
$status[] = '<fg=green>newer</>';
|
||||
}
|
||||
|
||||
if ($release['prerelease']) {
|
||||
$status[] = '<fg=yellow>pre-release</>';
|
||||
}
|
||||
|
||||
$table->addRow([
|
||||
$release['tag'],
|
||||
$version,
|
||||
(new \DateTime($release['published_at']))->format('Y-m-d'),
|
||||
implode(' ', $status) ?: '-',
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Use <info>php bin/console partdb:update [tag]</info> to update to a specific version.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function showLogs(SymfonyStyle $io): int
|
||||
{
|
||||
$logs = $this->updateExecutor->getUpdateLogs();
|
||||
|
||||
if (empty($logs)) {
|
||||
$io->info('No update logs found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->title('Recent Update Logs');
|
||||
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Date', 'File', 'Size']);
|
||||
|
||||
foreach (array_slice($logs, 0, 10) as $log) {
|
||||
$table->addRow([
|
||||
date('Y-m-d H:i:s', $log['date']),
|
||||
$log['file'],
|
||||
$this->formatBytes($log['size']),
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
$io->text('');
|
||||
$io->text('Log files are stored in: <info>var/log/updates/</info>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$unitIndex = 0;
|
||||
|
||||
while ($bytes >= 1024 && $unitIndex < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$unitIndex++;
|
||||
}
|
||||
|
||||
return sprintf('%.1f %s', $bytes, $units[$unitIndex]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue