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
224
src/Services/System/InstallationTypeDetector.php
Normal file
224
src/Services/System/InstallationTypeDetector.php
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<?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 Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Detects the installation type of Part-DB to determine the appropriate update strategy.
|
||||
*/
|
||||
enum InstallationType: string
|
||||
{
|
||||
case GIT = 'git';
|
||||
case DOCKER = 'docker';
|
||||
case ZIP_RELEASE = 'zip_release';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::GIT => 'Git Clone',
|
||||
self::DOCKER => 'Docker',
|
||||
self::ZIP_RELEASE => 'Release Archive',
|
||||
self::UNKNOWN => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
public function supportsAutoUpdate(): bool
|
||||
{
|
||||
return match($this) {
|
||||
self::GIT => true,
|
||||
self::DOCKER => false,
|
||||
self::ZIP_RELEASE => true,
|
||||
self::UNKNOWN => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function getUpdateInstructions(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::GIT => 'Run: php bin/console partdb:update',
|
||||
self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d',
|
||||
self::ZIP_RELEASE => 'Download the new release, extract it, and run migrations.',
|
||||
self::UNKNOWN => 'Unable to determine installation type. Please update manually.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class InstallationTypeDetector
|
||||
{
|
||||
public function __construct(#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the installation type based on filesystem markers.
|
||||
*/
|
||||
public function detect(): InstallationType
|
||||
{
|
||||
// Check for Docker environment first
|
||||
if ($this->isDocker()) {
|
||||
return InstallationType::DOCKER;
|
||||
}
|
||||
|
||||
// Check for Git installation
|
||||
if ($this->isGitInstall()) {
|
||||
return InstallationType::GIT;
|
||||
}
|
||||
|
||||
// Check for ZIP release (has VERSION file but no .git)
|
||||
if ($this->isZipRelease()) {
|
||||
return InstallationType::ZIP_RELEASE;
|
||||
}
|
||||
|
||||
return InstallationType::UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running inside a Docker container.
|
||||
*/
|
||||
public function isDocker(): bool
|
||||
{
|
||||
// Check for /.dockerenv file
|
||||
if (file_exists('/.dockerenv')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for DOCKER environment variable
|
||||
if (getenv('DOCKER') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for container runtime in cgroup
|
||||
if (file_exists('/proc/1/cgroup')) {
|
||||
$cgroup = @file_get_contents('/proc/1/cgroup');
|
||||
if ($cgroup !== false && (str_contains($cgroup, 'docker') || str_contains($cgroup, 'containerd'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a Git-based installation.
|
||||
*/
|
||||
public function isGitInstall(): bool
|
||||
{
|
||||
return is_dir($this->project_dir . '/.git');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this appears to be a ZIP release installation.
|
||||
*/
|
||||
public function isZipRelease(): bool
|
||||
{
|
||||
// Has VERSION file but no .git directory
|
||||
return file_exists($this->project_dir . '/VERSION') && !$this->isGitInstall();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about the installation.
|
||||
*/
|
||||
public function getInstallationInfo(): array
|
||||
{
|
||||
$type = $this->detect();
|
||||
|
||||
$info = [
|
||||
'type' => $type,
|
||||
'type_name' => $type->getLabel(),
|
||||
'supports_auto_update' => $type->supportsAutoUpdate(),
|
||||
'update_instructions' => $type->getUpdateInstructions(),
|
||||
'project_dir' => $this->project_dir,
|
||||
];
|
||||
|
||||
if ($type === InstallationType::GIT) {
|
||||
$info['git'] = $this->getGitInfo();
|
||||
}
|
||||
|
||||
if ($type === InstallationType::DOCKER) {
|
||||
$info['docker'] = $this->getDockerInfo();
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git-specific information.
|
||||
*/
|
||||
private function getGitInfo(): array
|
||||
{
|
||||
$info = [
|
||||
'branch' => null,
|
||||
'commit' => null,
|
||||
'remote_url' => null,
|
||||
'has_local_changes' => false,
|
||||
];
|
||||
|
||||
// Get branch
|
||||
$headFile = $this->project_dir . '/.git/HEAD';
|
||||
if (file_exists($headFile)) {
|
||||
$head = file_get_contents($headFile);
|
||||
if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) {
|
||||
$info['branch'] = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote URL
|
||||
$configFile = $this->project_dir . '/.git/config';
|
||||
if (file_exists($configFile)) {
|
||||
$config = file_get_contents($configFile);
|
||||
if (preg_match('#url = (.+)#', $config, $matches)) {
|
||||
$info['remote_url'] = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get commit hash
|
||||
$process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir);
|
||||
$process->run();
|
||||
if ($process->isSuccessful()) {
|
||||
$info['commit'] = trim($process->getOutput());
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
$info['has_local_changes'] = !empty(trim($process->getOutput()));
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker-specific information.
|
||||
*/
|
||||
private function getDockerInfo(): array
|
||||
{
|
||||
return [
|
||||
'container_id' => @file_get_contents('/proc/1/cpuset') ?: null,
|
||||
'image' => getenv('DOCKER_IMAGE') ?: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
349
src/Services/System/UpdateChecker.php
Normal file
349
src/Services/System/UpdateChecker.php
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
<?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 App\Settings\SystemSettings\PrivacySettings;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Version\Version;
|
||||
|
||||
/**
|
||||
* Enhanced update checker that fetches release information including changelogs.
|
||||
*/
|
||||
class UpdateChecker
|
||||
{
|
||||
private const GITHUB_API_BASE = 'https://api.github.com/repos/Part-DB/Part-DB-server';
|
||||
private const CACHE_KEY_RELEASES = 'update_checker_releases';
|
||||
private const CACHE_KEY_COMMITS = 'update_checker_commits_behind';
|
||||
private const CACHE_TTL = 60 * 60 * 6; // 6 hours
|
||||
private const CACHE_TTL_ERROR = 60 * 60; // 1 hour on error
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $httpClient,
|
||||
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
|
||||
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
|
||||
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode,
|
||||
#[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current installed version.
|
||||
*/
|
||||
public function getCurrentVersion(): Version
|
||||
{
|
||||
return $this->versionManager->getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version as string.
|
||||
*/
|
||||
public function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->getCurrentVersion()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git repository information.
|
||||
*/
|
||||
public function getGitInfo(): array
|
||||
{
|
||||
$info = [
|
||||
'branch' => null,
|
||||
'commit' => null,
|
||||
'has_local_changes' => false,
|
||||
'commits_behind' => 0,
|
||||
'is_git_install' => false,
|
||||
];
|
||||
|
||||
$gitDir = $this->project_dir . '/.git';
|
||||
|
||||
if (!is_dir($gitDir)) {
|
||||
return $info;
|
||||
}
|
||||
|
||||
$info['is_git_install'] = true;
|
||||
|
||||
// Get branch from HEAD file
|
||||
$headFile = $gitDir . '/HEAD';
|
||||
if (file_exists($headFile)) {
|
||||
$head = file_get_contents($headFile);
|
||||
if (preg_match('#ref: refs/heads/(.+)#', $head, $matches)) {
|
||||
$info['branch'] = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current commit
|
||||
$process = new Process(['git', 'rev-parse', '--short', 'HEAD'], $this->project_dir);
|
||||
$process->run();
|
||||
if ($process->isSuccessful()) {
|
||||
$info['commit'] = trim($process->getOutput());
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
$process = new Process(['git', 'status', '--porcelain'], $this->project_dir);
|
||||
$process->run();
|
||||
$info['has_local_changes'] = !empty(trim($process->getOutput()));
|
||||
|
||||
// Get commits behind (fetch first)
|
||||
if ($info['branch']) {
|
||||
// Try to get cached commits behind count
|
||||
$info['commits_behind'] = $this->getCommitsBehind($info['branch']);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of commits behind the remote branch (cached).
|
||||
*/
|
||||
private function getCommitsBehind(string $branch): int
|
||||
{
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cacheKey = self::CACHE_KEY_COMMITS . '_' . md5($branch);
|
||||
|
||||
return $this->updateCache->get($cacheKey, function (ItemInterface $item) use ($branch) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
|
||||
// Fetch from remote first
|
||||
$process = new Process(['git', 'fetch', '--tags', 'origin'], $this->project_dir);
|
||||
$process->run();
|
||||
|
||||
// Count commits behind
|
||||
$process = new Process(['git', 'rev-list', 'HEAD..origin/' . $branch, '--count'], $this->project_dir);
|
||||
$process->run();
|
||||
|
||||
return $process->isSuccessful() ? (int) trim($process->getOutput()) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh git information by invalidating cache.
|
||||
*/
|
||||
public function refreshGitInfo(): void
|
||||
{
|
||||
$gitInfo = $this->getGitInfo();
|
||||
if ($gitInfo['branch']) {
|
||||
$this->updateCache->delete(self::CACHE_KEY_COMMITS . '_' . md5($gitInfo['branch']));
|
||||
}
|
||||
$this->updateCache->delete(self::CACHE_KEY_RELEASES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available releases from GitHub (cached).
|
||||
*
|
||||
* @return array<array{version: string, tag: string, name: string, url: string, published_at: string, body: string, prerelease: bool, assets: array}>
|
||||
*/
|
||||
public function getAvailableReleases(int $limit = 10): array
|
||||
{
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->updateCache->get(self::CACHE_KEY_RELEASES, function (ItemInterface $item) use ($limit) {
|
||||
$item->expiresAfter(self::CACHE_TTL);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::GITHUB_API_BASE . '/releases', [
|
||||
'query' => ['per_page' => $limit],
|
||||
'headers' => [
|
||||
'Accept' => 'application/vnd.github.v3+json',
|
||||
'User-Agent' => 'Part-DB-Update-Checker',
|
||||
],
|
||||
]);
|
||||
|
||||
$releases = [];
|
||||
foreach ($response->toArray() as $release) {
|
||||
// Extract assets (for ZIP download)
|
||||
$assets = [];
|
||||
foreach ($release['assets'] ?? [] as $asset) {
|
||||
if (str_ends_with($asset['name'], '.zip') || str_ends_with($asset['name'], '.tar.gz')) {
|
||||
$assets[] = [
|
||||
'name' => $asset['name'],
|
||||
'url' => $asset['browser_download_url'],
|
||||
'size' => $asset['size'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$releases[] = [
|
||||
'version' => ltrim($release['tag_name'], 'v'),
|
||||
'tag' => $release['tag_name'],
|
||||
'name' => $release['name'] ?? $release['tag_name'],
|
||||
'url' => $release['html_url'],
|
||||
'published_at' => $release['published_at'],
|
||||
'body' => $release['body'] ?? '',
|
||||
'prerelease' => $release['prerelease'] ?? false,
|
||||
'draft' => $release['draft'] ?? false,
|
||||
'assets' => $assets,
|
||||
'tarball_url' => $release['tarball_url'] ?? null,
|
||||
'zipball_url' => $release['zipball_url'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $releases;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to fetch releases from GitHub: ' . $e->getMessage());
|
||||
$item->expiresAfter(self::CACHE_TTL_ERROR);
|
||||
|
||||
if ($this->is_dev_mode) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest stable release.
|
||||
*/
|
||||
public function getLatestRelease(bool $includePrerelease = false): ?array
|
||||
{
|
||||
$releases = $this->getAvailableReleases();
|
||||
|
||||
foreach ($releases as $release) {
|
||||
// Skip drafts always
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip prereleases unless explicitly included
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $release;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific version is newer than current.
|
||||
*/
|
||||
public function isNewerVersion(string $version): bool
|
||||
{
|
||||
try {
|
||||
$targetVersion = Version::fromString(ltrim($version, 'v'));
|
||||
return $targetVersion->isGreaterThan($this->getCurrentVersion());
|
||||
} catch (\Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive update status.
|
||||
*/
|
||||
public function getUpdateStatus(): array
|
||||
{
|
||||
$current = $this->getCurrentVersion();
|
||||
$latest = $this->getLatestRelease();
|
||||
$gitInfo = $this->getGitInfo();
|
||||
$installInfo = $this->installationTypeDetector->getInstallationInfo();
|
||||
|
||||
$updateAvailable = false;
|
||||
$latestVersion = null;
|
||||
$latestTag = null;
|
||||
|
||||
if ($latest) {
|
||||
try {
|
||||
$latestVersionObj = Version::fromString($latest['version']);
|
||||
$updateAvailable = $latestVersionObj->isGreaterThan($current);
|
||||
$latestVersion = $latest['version'];
|
||||
$latestTag = $latest['tag'];
|
||||
} catch (\Exception) {
|
||||
// Invalid version string
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we can auto-update
|
||||
$canAutoUpdate = $installInfo['supports_auto_update'];
|
||||
$updateBlockers = [];
|
||||
|
||||
if ($gitInfo['has_local_changes']) {
|
||||
$canAutoUpdate = false;
|
||||
$updateBlockers[] = 'local_changes';
|
||||
}
|
||||
|
||||
if ($installInfo['type'] === InstallationType::DOCKER) {
|
||||
$updateBlockers[] = 'docker_installation';
|
||||
}
|
||||
|
||||
return [
|
||||
'current_version' => $current->toString(),
|
||||
'latest_version' => $latestVersion,
|
||||
'latest_tag' => $latestTag,
|
||||
'update_available' => $updateAvailable,
|
||||
'release_notes' => $latest['body'] ?? null,
|
||||
'release_url' => $latest['url'] ?? null,
|
||||
'published_at' => $latest['published_at'] ?? null,
|
||||
'git' => $gitInfo,
|
||||
'installation' => $installInfo,
|
||||
'can_auto_update' => $canAutoUpdate,
|
||||
'update_blockers' => $updateBlockers,
|
||||
'check_enabled' => $this->privacySettings->checkForUpdates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get releases newer than the current version.
|
||||
*/
|
||||
public function getAvailableUpdates(bool $includePrerelease = false): array
|
||||
{
|
||||
$releases = $this->getAvailableReleases();
|
||||
$current = $this->getCurrentVersion();
|
||||
$updates = [];
|
||||
|
||||
foreach ($releases as $release) {
|
||||
if ($release['draft']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$includePrerelease && $release['prerelease']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$releaseVersion = Version::fromString($release['version']);
|
||||
if ($releaseVersion->isGreaterThan($current)) {
|
||||
$updates[] = $release;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
}
|
||||
832
src/Services/System/UpdateExecutor.php
Normal file
832
src/Services/System/UpdateExecutor.php
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
<?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.
|
||||
*/
|
||||
class UpdateExecutor
|
||||
{
|
||||
private const LOCK_FILE = 'var/update.lock';
|
||||
private const MAINTENANCE_FILE = 'var/maintenance.flag';
|
||||
private const UPDATE_LOG_DIR = 'var/log/updates';
|
||||
private const BACKUP_DIR = 'var/backups';
|
||||
private const PROGRESS_FILE = 'var/update_progress.json';
|
||||
|
||||
/** @var array<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 VersionManagerInterface $versionManager)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version string for use in filenames.
|
||||
*/
|
||||
private function getCurrentVersionString(): string
|
||||
{
|
||||
return $this->versionManager->getVersion()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is currently in progress.
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (!file_exists($lockFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if lock is stale (older than 1 hour)
|
||||
$lockData = json_decode(file_get_contents($lockFile), true);
|
||||
if ($lockData && isset($lockData['started_at'])) {
|
||||
$startedAt = new \DateTime($lockData['started_at']);
|
||||
$now = new \DateTime();
|
||||
$diff = $now->getTimestamp() - $startedAt->getTimestamp();
|
||||
|
||||
// If lock is older than 1 hour, consider it stale
|
||||
if ($diff > 3600) {
|
||||
$this->logger->warning('Found stale update lock, removing it');
|
||||
$this->releaseLock();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lock information.
|
||||
*/
|
||||
public function getLockInfo(): ?array
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (!file_exists($lockFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($lockFile), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if maintenance mode is enabled.
|
||||
*/
|
||||
public function isMaintenanceMode(): bool
|
||||
{
|
||||
return file_exists($this->project_dir . '/' . self::MAINTENANCE_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance mode information.
|
||||
*/
|
||||
public function getMaintenanceInfo(): ?array
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
|
||||
if (!file_exists($maintenanceFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($maintenanceFile), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an exclusive lock for the update process.
|
||||
*/
|
||||
public function acquireLock(): bool
|
||||
{
|
||||
if ($this->isLocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
$lockDir = dirname($lockFile);
|
||||
|
||||
if (!is_dir($lockDir)) {
|
||||
$this->filesystem->mkdir($lockDir);
|
||||
}
|
||||
|
||||
$lockData = [
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'pid' => getmypid(),
|
||||
'user' => get_current_user(),
|
||||
];
|
||||
|
||||
$this->filesystem->dumpFile($lockFile, json_encode($lockData, JSON_PRETTY_PRINT));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the update lock.
|
||||
*/
|
||||
public function releaseLock(): void
|
||||
{
|
||||
$lockFile = $this->project_dir . '/' . self::LOCK_FILE;
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
$this->filesystem->remove($lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable maintenance mode to block user access during update.
|
||||
*/
|
||||
public function enableMaintenanceMode(string $reason = 'Update in progress'): void
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
$maintenanceDir = dirname($maintenanceFile);
|
||||
|
||||
if (!is_dir($maintenanceDir)) {
|
||||
$this->filesystem->mkdir($maintenanceDir);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'enabled_at' => (new \DateTime())->format('c'),
|
||||
'reason' => $reason,
|
||||
];
|
||||
|
||||
$this->filesystem->dumpFile($maintenanceFile, json_encode($data, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable maintenance mode.
|
||||
*/
|
||||
public function disableMaintenanceMode(): void
|
||||
{
|
||||
$maintenanceFile = $this->project_dir . '/' . self::MAINTENANCE_FILE;
|
||||
|
||||
if (file_exists($maintenanceFile)) {
|
||||
$this->filesystem->remove($maintenanceFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that we can perform an update.
|
||||
*
|
||||
* @return array{valid: bool, errors: array<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 write permissions
|
||||
$testDirs = ['var', 'vendor', 'public'];
|
||||
foreach ($testDirs as $dir) {
|
||||
$fullPath = $this->project_dir . '/' . $dir;
|
||||
if (is_dir($fullPath) && !is_writable($fullPath)) {
|
||||
$errors[] = sprintf('Directory "%s" is not writable.', $dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already locked
|
||||
if ($this->isLocked()) {
|
||||
$lockInfo = $this->getLockInfo();
|
||||
$errors[] = sprintf(
|
||||
'An update is already in progress (started at %s).',
|
||||
$lockInfo['started_at'] ?? 'unknown time'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the update to a specific version.
|
||||
*
|
||||
* @param string $targetVersion The target version/tag to update to (e.g., "v2.6.0")
|
||||
* @param bool $createBackup Whether to create a backup before updating
|
||||
* @param callable|null $onProgress Callback for progress updates
|
||||
*
|
||||
* @return array{success: bool, steps: array, rollback_tag: ?string, error: ?string, log_file: ?string}
|
||||
*/
|
||||
public function executeUpdate(
|
||||
string $targetVersion,
|
||||
bool $createBackup = true,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$this->steps = [];
|
||||
$rollbackTag = null;
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Initialize log file
|
||||
$this->initializeLogFile($targetVersion);
|
||||
|
||||
$log = function (string $step, string $message, bool $success = true, ?float $duration = null) use ($onProgress): void {
|
||||
$entry = [
|
||||
'step' => $step,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
'duration' => $duration,
|
||||
];
|
||||
|
||||
$this->steps[] = $entry;
|
||||
$this->writeToLogFile($entry);
|
||||
$this->logger->info("Update [{$step}]: {$message}", ['success' => $success]);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate preconditions
|
||||
$validation = $this->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
throw new \RuntimeException('Precondition check failed: ' . implode('; ', $validation['errors']));
|
||||
}
|
||||
|
||||
// Step 1: Acquire lock
|
||||
$stepStart = microtime(true);
|
||||
if (!$this->acquireLock()) {
|
||||
throw new \RuntimeException('Could not acquire update lock. Another update may be in progress.');
|
||||
}
|
||||
$log('lock', 'Acquired exclusive update lock', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 2: Enable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->enableMaintenanceMode('Updating to ' . $targetVersion);
|
||||
$log('maintenance', 'Enabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 3: Create rollback point with version info
|
||||
$stepStart = microtime(true);
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$rollbackTag = 'pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His');
|
||||
$this->runCommand(['git', 'tag', $rollbackTag], 'Create rollback tag');
|
||||
$log('rollback_tag', 'Created rollback tag: ' . $rollbackTag, true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 4: Create backup (optional)
|
||||
if ($createBackup) {
|
||||
$stepStart = microtime(true);
|
||||
$backupFile = $this->createBackup($targetVersion);
|
||||
$log('backup', 'Created backup: ' . basename($backupFile), true, microtime(true) - $stepStart);
|
||||
}
|
||||
|
||||
// Step 5: Fetch from remote
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['git', 'fetch', '--tags', '--force', 'origin'], 'Fetch from origin', 120);
|
||||
$log('fetch', 'Fetched latest changes and tags from origin', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 6: Checkout target version
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand(['git', 'checkout', $targetVersion], 'Checkout version');
|
||||
$log('checkout', 'Checked out version: ' . $targetVersion, true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 7: Install dependencies
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'composer', 'install',
|
||||
'--no-dev',
|
||||
'--optimize-autoloader',
|
||||
'--no-interaction',
|
||||
'--no-progress',
|
||||
], 'Install dependencies', 600);
|
||||
$log('composer', 'Installed/updated dependencies', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 8: Run database migrations
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'doctrine:migrations:migrate',
|
||||
'--no-interaction',
|
||||
'--allow-no-migration',
|
||||
], 'Run migrations', 300);
|
||||
$log('migrations', 'Database migrations completed', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 9: Clear cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'--env=prod',
|
||||
'--no-interaction',
|
||||
], 'Clear cache', 120);
|
||||
$log('cache_clear', 'Cleared application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 10: Warm up cache
|
||||
$stepStart = microtime(true);
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:warmup',
|
||||
'--env=prod',
|
||||
], 'Warmup cache', 120);
|
||||
$log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 11: Disable maintenance mode
|
||||
$stepStart = microtime(true);
|
||||
$this->disableMaintenanceMode();
|
||||
$log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart);
|
||||
|
||||
// Step 12: Release lock
|
||||
$stepStart = microtime(true);
|
||||
$this->releaseLock();
|
||||
|
||||
$totalDuration = microtime(true) - $startTime;
|
||||
$log('complete', sprintf('Update completed successfully in %.1f seconds', $totalDuration), true, microtime(true) - $stepStart);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'steps' => $this->steps,
|
||||
'rollback_tag' => $rollbackTag,
|
||||
'error' => null,
|
||||
'log_file' => $this->currentLogFile,
|
||||
'duration' => $totalDuration,
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$log('error', 'Update failed: ' . $e->getMessage(), false);
|
||||
|
||||
// Attempt rollback
|
||||
if ($rollbackTag) {
|
||||
try {
|
||||
$this->runCommand(['git', 'checkout', $rollbackTag], 'Rollback');
|
||||
$log('rollback', 'Rolled back to: ' . $rollbackTag, true);
|
||||
|
||||
// Re-run composer install after rollback
|
||||
$this->runCommand([
|
||||
'composer', 'install',
|
||||
'--no-dev',
|
||||
'--optimize-autoloader',
|
||||
'--no-interaction',
|
||||
], 'Reinstall dependencies after rollback', 600);
|
||||
$log('rollback_composer', 'Reinstalled dependencies after rollback', true);
|
||||
|
||||
// Clear cache after rollback
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'cache:clear',
|
||||
'--env=prod',
|
||||
], 'Clear cache after rollback', 120);
|
||||
$log('rollback_cache', 'Cleared cache after rollback', true);
|
||||
|
||||
} catch (\Exception $rollbackError) {
|
||||
$log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
$this->disableMaintenanceMode();
|
||||
$this->releaseLock();
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'steps' => $this->steps,
|
||||
'rollback_tag' => $rollbackTag,
|
||||
'error' => $e->getMessage(),
|
||||
'log_file' => $this->currentLogFile,
|
||||
'duration' => microtime(true) - $startTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup before updating.
|
||||
*/
|
||||
private function createBackup(string $targetVersion): string
|
||||
{
|
||||
$backupDir = $this->project_dir . '/' . self::BACKUP_DIR;
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
$this->filesystem->mkdir($backupDir, 0755);
|
||||
}
|
||||
|
||||
// Include version numbers in backup filename: pre-update-v2.5.1-to-v2.6.0-2024-01-30-185400.zip
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$backupFile = $backupDir . '/pre-update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.zip';
|
||||
|
||||
$this->runCommand([
|
||||
'php', 'bin/console', 'partdb:backup',
|
||||
'--full',
|
||||
'--overwrite',
|
||||
$backupFile,
|
||||
], 'Create backup', 600);
|
||||
|
||||
return $backupFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a shell command with proper error handling.
|
||||
*/
|
||||
private function runCommand(array $command, string $description, int $timeout = 120): string
|
||||
{
|
||||
$process = new Process($command, $this->project_dir);
|
||||
$process->setTimeout($timeout);
|
||||
|
||||
// Set environment variables needed for Composer and other tools
|
||||
// This is especially important when running as www-data which may not have HOME set
|
||||
// We inherit from current environment and override/add specific variables
|
||||
$currentEnv = getenv();
|
||||
if (!is_array($currentEnv)) {
|
||||
$currentEnv = [];
|
||||
}
|
||||
$env = array_merge($currentEnv, [
|
||||
'HOME' => $this->project_dir,
|
||||
'COMPOSER_HOME' => $this->project_dir . '/var/composer',
|
||||
'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin',
|
||||
]);
|
||||
$process->setEnv($env);
|
||||
|
||||
$output = '';
|
||||
$process->run(function ($type, $buffer) use (&$output) {
|
||||
$output .= $buffer;
|
||||
});
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$errorOutput = $process->getErrorOutput() ?: $process->getOutput();
|
||||
throw new \RuntimeException(
|
||||
sprintf('%s failed: %s', $description, trim($errorOutput))
|
||||
);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the log file for this update.
|
||||
*/
|
||||
private function initializeLogFile(string $targetVersion): void
|
||||
{
|
||||
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
$this->filesystem->mkdir($logDir, 0755);
|
||||
}
|
||||
|
||||
// Include version numbers in log filename: update-v2.5.1-to-v2.6.0-2024-01-30-185400.log
|
||||
$currentVersion = $this->getCurrentVersionString();
|
||||
$targetVersionClean = preg_replace('/[^a-zA-Z0-9\.]/', '', $targetVersion);
|
||||
$this->currentLogFile = $logDir . '/update-v' . $currentVersion . '-to-' . $targetVersionClean . '-' . date('Y-m-d-His') . '.log';
|
||||
|
||||
$header = sprintf(
|
||||
"Part-DB Update Log\n" .
|
||||
"==================\n" .
|
||||
"Started: %s\n" .
|
||||
"From Version: %s\n" .
|
||||
"Target Version: %s\n" .
|
||||
"==================\n\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
$currentVersion,
|
||||
$targetVersion
|
||||
);
|
||||
|
||||
file_put_contents($this->currentLogFile, $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the log file.
|
||||
*/
|
||||
private function writeToLogFile(array $entry): void
|
||||
{
|
||||
if (!$this->currentLogFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
$line = sprintf(
|
||||
"[%s] %s: %s%s\n",
|
||||
$entry['timestamp'],
|
||||
strtoupper($entry['step']),
|
||||
$entry['message'],
|
||||
$entry['duration'] ? sprintf(' (%.2fs)', $entry['duration']) : ''
|
||||
);
|
||||
|
||||
file_put_contents($this->currentLogFile, $line, FILE_APPEND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of update log files.
|
||||
*/
|
||||
public function getUpdateLogs(): array
|
||||
{
|
||||
$logDir = $this->project_dir . '/' . self::UPDATE_LOG_DIR;
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
foreach (glob($logDir . '/update-*.log') as $logFile) {
|
||||
$logs[] = [
|
||||
'file' => basename($logFile),
|
||||
'path' => $logFile,
|
||||
'date' => filemtime($logFile),
|
||||
'size' => filesize($logFile),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($logs, fn($a, $b) => $b['date'] <=> $a['date']);
|
||||
|
||||
return $logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of backups.
|
||||
*/
|
||||
public function getBackups(): array
|
||||
{
|
||||
$backupDir = $this->project_dir . '/' . self::BACKUP_DIR;
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$backups = [];
|
||||
foreach (glob($backupDir . '/*.zip') as $backupFile) {
|
||||
$backups[] = [
|
||||
'file' => basename($backupFile),
|
||||
'path' => $backupFile,
|
||||
'date' => filemtime($backupFile),
|
||||
'size' => filesize($backupFile),
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
usort($backups, fn($a, $b) => $b['date'] <=> $a['date']);
|
||||
|
||||
return $backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the progress file.
|
||||
*/
|
||||
public function getProgressFilePath(): string
|
||||
{
|
||||
return $this->project_dir . '/' . self::PROGRESS_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save progress to file for web UI polling.
|
||||
*/
|
||||
public function saveProgress(array $progress): void
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
$progressDir = dirname($progressFile);
|
||||
|
||||
if (!is_dir($progressDir)) {
|
||||
$this->filesystem->mkdir($progressDir);
|
||||
}
|
||||
|
||||
$this->filesystem->dumpFile($progressFile, json_encode($progress, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current update progress from file.
|
||||
*/
|
||||
public function getProgress(): ?array
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
|
||||
if (!file_exists($progressFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($progressFile), true);
|
||||
|
||||
// If the progress file is stale (older than 30 minutes), consider it invalid
|
||||
if ($data && isset($data['started_at'])) {
|
||||
$startedAt = strtotime($data['started_at']);
|
||||
if (time() - $startedAt > 1800) {
|
||||
$this->clearProgress();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear progress file.
|
||||
*/
|
||||
public function clearProgress(): void
|
||||
{
|
||||
$progressFile = $this->getProgressFilePath();
|
||||
|
||||
if (file_exists($progressFile)) {
|
||||
$this->filesystem->remove($progressFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an update is currently running (based on progress file).
|
||||
*/
|
||||
public function isUpdateRunning(): bool
|
||||
{
|
||||
$progress = $this->getProgress();
|
||||
|
||||
if (!$progress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($progress['status']) && $progress['status'] === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the update process in the background.
|
||||
* Returns the process ID or null on failure.
|
||||
*/
|
||||
public function startBackgroundUpdate(string $targetVersion, bool $createBackup = true): ?int
|
||||
{
|
||||
// Validate first
|
||||
$validation = $this->validateUpdatePreconditions();
|
||||
if (!$validation['valid']) {
|
||||
$this->logger->error('Update validation failed', ['errors' => $validation['errors']]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize progress file
|
||||
$this->saveProgress([
|
||||
'status' => 'starting',
|
||||
'target_version' => $targetVersion,
|
||||
'create_backup' => $createBackup,
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'current_step' => 0,
|
||||
'total_steps' => 12,
|
||||
'step_name' => 'initializing',
|
||||
'step_message' => 'Starting update process...',
|
||||
'steps' => [],
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
// Build the command to run in background
|
||||
// Use 'php' from PATH as PHP_BINARY might point to php-fpm
|
||||
$consolePath = $this->project_dir . '/bin/console';
|
||||
$logFile = $this->project_dir . '/var/log/update-background.log';
|
||||
|
||||
// Ensure log directory exists
|
||||
$logDir = dirname($logFile);
|
||||
if (!is_dir($logDir)) {
|
||||
$this->filesystem->mkdir($logDir, 0755);
|
||||
}
|
||||
|
||||
// Use nohup to properly detach the process from the web request
|
||||
// The process will continue running even after the PHP request ends
|
||||
$command = sprintf(
|
||||
'nohup php %s partdb:update %s %s --force --no-interaction >> %s 2>&1 &',
|
||||
escapeshellarg($consolePath),
|
||||
escapeshellarg($targetVersion),
|
||||
$createBackup ? '' : '--no-backup',
|
||||
escapeshellarg($logFile)
|
||||
);
|
||||
|
||||
$this->logger->info('Starting background update', [
|
||||
'command' => $command,
|
||||
'target_version' => $targetVersion,
|
||||
]);
|
||||
|
||||
// Execute in background using shell_exec for proper detachment
|
||||
// shell_exec with & runs the command in background
|
||||
$output = shell_exec($command);
|
||||
|
||||
// Give it a moment to start
|
||||
usleep(500000); // 500ms
|
||||
|
||||
// Check if progress file was updated (indicates process started)
|
||||
$progress = $this->getProgress();
|
||||
if ($progress && isset($progress['status'])) {
|
||||
$this->logger->info('Background update started successfully');
|
||||
return 1; // Return a non-null value to indicate success
|
||||
}
|
||||
|
||||
$this->logger->error('Background update may not have started', ['output' => $output]);
|
||||
return 1; // Still return success as the process might just be slow to start
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute update with progress file updates for web UI.
|
||||
* This is called by the CLI command and updates the progress file.
|
||||
*/
|
||||
public function executeUpdateWithProgress(
|
||||
string $targetVersion,
|
||||
bool $createBackup = true,
|
||||
?callable $onProgress = null
|
||||
): array {
|
||||
$totalSteps = 12;
|
||||
$currentStep = 0;
|
||||
|
||||
$updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void {
|
||||
$currentStep++;
|
||||
$progress = $this->getProgress() ?? [
|
||||
'status' => 'running',
|
||||
'target_version' => $targetVersion,
|
||||
'create_backup' => $createBackup,
|
||||
'started_at' => (new \DateTime())->format('c'),
|
||||
'steps' => [],
|
||||
];
|
||||
|
||||
$progress['current_step'] = $currentStep;
|
||||
$progress['total_steps'] = $totalSteps;
|
||||
$progress['step_name'] = $stepName;
|
||||
$progress['step_message'] = $message;
|
||||
$progress['status'] = 'running';
|
||||
$progress['steps'][] = [
|
||||
'step' => $stepName,
|
||||
'message' => $message,
|
||||
'success' => $success,
|
||||
'timestamp' => (new \DateTime())->format('c'),
|
||||
];
|
||||
|
||||
$this->saveProgress($progress);
|
||||
};
|
||||
|
||||
// Wrap the existing executeUpdate with progress tracking
|
||||
$result = $this->executeUpdate($targetVersion, $createBackup, function ($entry) use ($updateProgress, $onProgress) {
|
||||
$updateProgress($entry['step'], $entry['message'], $entry['success']);
|
||||
|
||||
if ($onProgress) {
|
||||
$onProgress($entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Update final status
|
||||
$finalProgress = $this->getProgress() ?? [];
|
||||
$finalProgress['status'] = $result['success'] ? 'completed' : 'failed';
|
||||
$finalProgress['completed_at'] = (new \DateTime())->format('c');
|
||||
$finalProgress['result'] = $result;
|
||||
$finalProgress['error'] = $result['error'];
|
||||
$this->saveProgress($finalProgress);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -315,6 +315,13 @@ class ToolsTreeBuilder
|
|||
))->setIcon('fa fa-fw fa-gears fa-solid');
|
||||
}
|
||||
|
||||
if ($this->security->isGranted('@system.show_updates')) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.system.update_manager'),
|
||||
$this->urlGenerator->generate('admin_update_manager')
|
||||
))->setIcon('fa-fw fa-treeview fa-solid fa-cloud-download-alt');
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue