2025-10-16 10:59:50 -06:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @package Grav\Plugin\Admin
|
|
|
|
|
*
|
|
|
|
|
* Handles Safe Upgrade orchestration for the Admin plugin.
|
|
|
|
|
*
|
|
|
|
|
* This class mirrors the behaviour offered by the CLI `bin/gpm self-upgrade`
|
|
|
|
|
* command while exposing a task-oriented API suitable for AJAX interactions.
|
|
|
|
|
*
|
|
|
|
|
* IMPORTANT: Keep this implementation aligned with
|
|
|
|
|
* `Grav\Console\Gpm\SelfupgradeCommand` whenever logic changes there.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
namespace Grav\Plugin\Admin;
|
|
|
|
|
|
|
|
|
|
use Grav\Common\Filesystem\Folder;
|
|
|
|
|
use Grav\Common\GPM\Installer;
|
|
|
|
|
use Grav\Common\GPM\Upgrader;
|
|
|
|
|
use Grav\Common\Grav;
|
|
|
|
|
use Grav\Common\HTTP\Response;
|
|
|
|
|
use Grav\Common\Recovery\RecoveryManager;
|
|
|
|
|
use Grav\Common\Upgrade\SafeUpgradeService;
|
2025-10-16 17:31:57 -06:00
|
|
|
use Grav\Common\Utils;
|
2025-10-16 10:59:50 -06:00
|
|
|
use Grav\Installer\Install;
|
2025-10-16 17:59:05 -06:00
|
|
|
use Symfony\Component\Process\PhpExecutableFinder;
|
|
|
|
|
use Symfony\Component\Process\Process;
|
2025-10-16 10:59:50 -06:00
|
|
|
use RuntimeException;
|
|
|
|
|
use Throwable;
|
|
|
|
|
use ZipArchive;
|
|
|
|
|
use function class_exists;
|
|
|
|
|
use function dirname;
|
|
|
|
|
use function file_exists;
|
|
|
|
|
use function file_get_contents;
|
|
|
|
|
use function file_put_contents;
|
|
|
|
|
use function glob;
|
|
|
|
|
use function is_array;
|
|
|
|
|
use function is_dir;
|
|
|
|
|
use function is_file;
|
|
|
|
|
use function json_decode;
|
|
|
|
|
use function json_encode;
|
|
|
|
|
use function max;
|
|
|
|
|
use function rsort;
|
|
|
|
|
use function sprintf;
|
|
|
|
|
use function strftime;
|
|
|
|
|
use function strtotime;
|
|
|
|
|
use function time;
|
|
|
|
|
use function uniqid;
|
|
|
|
|
use const GRAV_ROOT;
|
|
|
|
|
use const GRAV_SCHEMA;
|
|
|
|
|
|
|
|
|
|
class SafeUpgradeManager
|
|
|
|
|
{
|
|
|
|
|
private const PROGRESS_FILENAME = 'safe-upgrade-progress.json';
|
2025-10-16 17:31:57 -06:00
|
|
|
private const JOB_MANIFEST = 'manifest.json';
|
|
|
|
|
private const JOB_PROGRESS = 'progress.json';
|
2025-10-16 10:59:50 -06:00
|
|
|
|
|
|
|
|
/** @var Grav */
|
|
|
|
|
private $grav;
|
|
|
|
|
/** @var Upgrader|null */
|
|
|
|
|
private $upgrader;
|
|
|
|
|
/** @var SafeUpgradeService|null */
|
|
|
|
|
private $safeUpgrade;
|
|
|
|
|
/** @var RecoveryManager */
|
|
|
|
|
private $recovery;
|
|
|
|
|
/** @var string */
|
2025-10-16 17:31:57 -06:00
|
|
|
private $progressDir;
|
|
|
|
|
/** @var string */
|
|
|
|
|
private $jobsDir;
|
2025-10-16 17:59:05 -06:00
|
|
|
/** @var \Psr\Log\LoggerInterface|null */
|
|
|
|
|
private $logger;
|
2025-10-16 17:31:57 -06:00
|
|
|
/** @var string|null */
|
|
|
|
|
private $jobId;
|
|
|
|
|
/** @var string|null */
|
|
|
|
|
private $jobManifestPath;
|
|
|
|
|
/** @var string */
|
2025-10-16 10:59:50 -06:00
|
|
|
private $progressPath;
|
|
|
|
|
/** @var string|null */
|
|
|
|
|
private $tmp;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* SafeUpgradeManager constructor.
|
|
|
|
|
*
|
|
|
|
|
* @param Grav|null $grav
|
|
|
|
|
*/
|
|
|
|
|
public function __construct(?Grav $grav = null)
|
|
|
|
|
{
|
|
|
|
|
$this->grav = $grav ?? Grav::instance();
|
|
|
|
|
$this->recovery = $this->grav['recovery'];
|
2025-10-16 17:59:05 -06:00
|
|
|
$this->logger = $this->grav['log'] ?? null;
|
2025-10-16 10:59:50 -06:00
|
|
|
|
|
|
|
|
$locator = $this->grav['locator'];
|
2025-10-16 17:31:57 -06:00
|
|
|
$this->progressDir = $locator->findResource('user://data/upgrades', true, true);
|
|
|
|
|
$this->jobsDir = $this->progressDir . '/jobs';
|
|
|
|
|
|
|
|
|
|
Folder::create($this->jobsDir);
|
|
|
|
|
|
|
|
|
|
$this->setJobId(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function setJobId(?string $jobId): void
|
|
|
|
|
{
|
|
|
|
|
$this->jobId = $jobId ?: null;
|
|
|
|
|
|
|
|
|
|
if ($this->jobId) {
|
|
|
|
|
$jobDir = $this->getJobDir($this->jobId);
|
|
|
|
|
Folder::create($jobDir);
|
|
|
|
|
$this->jobManifestPath = $jobDir . '/' . self::JOB_MANIFEST;
|
|
|
|
|
$this->progressPath = $jobDir . '/' . self::JOB_PROGRESS;
|
2025-10-16 17:59:05 -06:00
|
|
|
$this->log(sprintf('Safe upgrade job %s activated', $this->jobId), 'debug');
|
2025-10-16 17:31:57 -06:00
|
|
|
} else {
|
|
|
|
|
$this->jobManifestPath = null;
|
|
|
|
|
$this->progressPath = $this->progressDir . '/' . self::PROGRESS_FILENAME;
|
2025-10-16 17:59:05 -06:00
|
|
|
$this->log('Safe upgrade job context cleared', 'debug');
|
2025-10-16 17:31:57 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function clearJobContext(): void
|
|
|
|
|
{
|
|
|
|
|
$this->setJobId(null);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-18 12:04:25 -06:00
|
|
|
/**
|
|
|
|
|
* @return array<int, array{id: string, source_version:?string, target_version:?string, created_at:int, created_at_iso:?string, backup_path:?string, package_path:?string}>
|
|
|
|
|
*/
|
|
|
|
|
public function listSnapshots(): array
|
|
|
|
|
{
|
|
|
|
|
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
|
|
|
|
if (!is_dir($manifestDir)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$files = glob($manifestDir . '/*.json') ?: [];
|
|
|
|
|
rsort($files);
|
|
|
|
|
|
|
|
|
|
$snapshots = [];
|
|
|
|
|
foreach ($files as $file) {
|
|
|
|
|
$decoded = json_decode(file_get_contents($file) ?: '', true);
|
|
|
|
|
if (!is_array($decoded) || empty($decoded['id'])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$createdAt = isset($decoded['created_at']) ? (int)$decoded['created_at'] : 0;
|
|
|
|
|
|
|
|
|
|
$snapshots[] = [
|
|
|
|
|
'id' => (string)$decoded['id'],
|
|
|
|
|
'source_version' => $decoded['source_version'] ?? null,
|
|
|
|
|
'target_version' => $decoded['target_version'] ?? null,
|
|
|
|
|
'created_at' => $createdAt,
|
|
|
|
|
'created_at_iso' => $createdAt > 0 ? date('c', $createdAt) : null,
|
|
|
|
|
'backup_path' => $decoded['backup_path'] ?? null,
|
|
|
|
|
'package_path' => $decoded['package_path'] ?? null,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $snapshots;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function hasSnapshots(): bool
|
|
|
|
|
{
|
|
|
|
|
return !empty($this->listSnapshots());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string $snapshotId
|
|
|
|
|
* @return array{status:string,message:?string,manifest:array|null}
|
|
|
|
|
*/
|
|
|
|
|
public function restoreSnapshot(string $snapshotId): array
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
$safeUpgrade = $this->getSafeUpgradeService();
|
|
|
|
|
$manifest = $safeUpgrade->rollback($snapshotId);
|
|
|
|
|
} catch (RuntimeException $e) {
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => $e->getMessage(),
|
|
|
|
|
'manifest' => null,
|
|
|
|
|
];
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => $e->getMessage(),
|
|
|
|
|
'manifest' => null,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$manifest) {
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => sprintf('Snapshot %s not found.', $snapshotId),
|
|
|
|
|
'manifest' => null,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => null,
|
|
|
|
|
'manifest' => $manifest,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-18 13:48:40 -06:00
|
|
|
public function queueRestore(string $snapshotId): array
|
|
|
|
|
{
|
|
|
|
|
$snapshotId = trim($snapshotId);
|
|
|
|
|
if ($snapshotId === '') {
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => 'Snapshot identifier is required.',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$manifestPath = GRAV_ROOT . '/user/data/upgrades/' . $snapshotId . '.json';
|
|
|
|
|
if (!is_file($manifestPath)) {
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => sprintf('Snapshot %s not found.', $snapshotId),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->queue([
|
|
|
|
|
'operation' => 'restore',
|
|
|
|
|
'snapshot_id' => $snapshotId,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-18 12:04:25 -06:00
|
|
|
/**
|
|
|
|
|
* @param array<int, string> $snapshotIds
|
|
|
|
|
* @return array<int, array{id:string,status:string,message:?string}>
|
|
|
|
|
*/
|
|
|
|
|
public function deleteSnapshots(array $snapshotIds): array
|
|
|
|
|
{
|
|
|
|
|
$ids = array_values(array_unique(array_filter(array_map('strval', $snapshotIds))));
|
|
|
|
|
$results = [];
|
|
|
|
|
|
|
|
|
|
foreach ($ids as $id) {
|
|
|
|
|
$results[] = $this->deleteSnapshot($id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string $snapshotId
|
|
|
|
|
* @return array{id:string,status:string,message:?string}
|
|
|
|
|
*/
|
|
|
|
|
protected function deleteSnapshot(string $snapshotId): array
|
|
|
|
|
{
|
|
|
|
|
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
|
|
|
|
$manifestPath = $manifestDir . '/' . $snapshotId . '.json';
|
|
|
|
|
|
|
|
|
|
if (!is_file($manifestPath)) {
|
|
|
|
|
return [
|
|
|
|
|
'id' => $snapshotId,
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => sprintf('Snapshot %s not found.', $snapshotId),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$manifest = json_decode(file_get_contents($manifestPath) ?: '', true);
|
|
|
|
|
if (!is_array($manifest)) {
|
|
|
|
|
return [
|
|
|
|
|
'id' => $snapshotId,
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => sprintf('Snapshot %s manifest is corrupted.', $snapshotId),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
foreach (['package_path', 'backup_path'] as $key) {
|
|
|
|
|
$path = isset($manifest[$key]) ? (string)$manifest[$key] : '';
|
|
|
|
|
if ($path === '' || !file_exists($path)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (is_dir($path)) {
|
|
|
|
|
Folder::delete($path);
|
|
|
|
|
} else {
|
|
|
|
|
@unlink($path);
|
|
|
|
|
}
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
$errors[] = $e->getMessage();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!@unlink($manifestPath)) {
|
|
|
|
|
$errors[] = sprintf('Unable to delete manifest file %s.', $manifestPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($errors) {
|
|
|
|
|
return [
|
|
|
|
|
'id' => $snapshotId,
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => implode(' ', $errors),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'id' => $snapshotId,
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => sprintf('Snapshot %s removed.', $snapshotId),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 17:31:57 -06:00
|
|
|
protected function getJobDir(string $jobId): string
|
|
|
|
|
{
|
|
|
|
|
return $this->jobsDir . '/' . $jobId;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 17:59:05 -06:00
|
|
|
protected function generateJobId(): string
|
2025-10-16 17:31:57 -06:00
|
|
|
{
|
2025-10-16 17:59:05 -06:00
|
|
|
return 'job-' . gmdate('YmdHis') . '-' . substr(md5(uniqid('', true)), 0, 8);
|
2025-10-16 17:31:57 -06:00
|
|
|
}
|
|
|
|
|
|
2025-10-16 17:59:05 -06:00
|
|
|
protected function log(string $message, string $level = 'info'): void
|
2025-10-16 17:31:57 -06:00
|
|
|
{
|
2025-10-16 17:59:05 -06:00
|
|
|
if (!$this->logger) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (method_exists($this->logger, $level)) {
|
|
|
|
|
$this->logger->$level('[SafeUpgrade] ' . $message);
|
|
|
|
|
} else {
|
|
|
|
|
$this->logger->info('[SafeUpgrade] ' . $message);
|
|
|
|
|
}
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
// ignore logging errors
|
|
|
|
|
}
|
2025-10-16 17:31:57 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function writeManifest(array $data): void
|
|
|
|
|
{
|
|
|
|
|
if (!$this->jobManifestPath) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$existing = [];
|
|
|
|
|
if (is_file($this->jobManifestPath)) {
|
|
|
|
|
$decoded = json_decode((string)file_get_contents($this->jobManifestPath), true);
|
|
|
|
|
if (is_array($decoded)) {
|
|
|
|
|
$existing = $decoded;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$payload = $existing + [
|
|
|
|
|
'id' => $this->jobId,
|
|
|
|
|
'created_at' => time(),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$payload = array_merge($payload, $data, [
|
|
|
|
|
'updated_at' => time(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Folder::create(dirname($this->jobManifestPath));
|
|
|
|
|
file_put_contents($this->jobManifestPath, json_encode($payload, JSON_PRETTY_PRINT));
|
2025-10-16 17:59:05 -06:00
|
|
|
if (!empty($data['status'])) {
|
|
|
|
|
$this->log(sprintf('Job %s status -> %s', $payload['id'] ?? $this->jobId ?? 'unknown', $data['status']), 'debug');
|
|
|
|
|
}
|
2025-10-16 17:31:57 -06:00
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
// ignore manifest write failures
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function updateJob(array $data): void
|
|
|
|
|
{
|
|
|
|
|
$this->writeManifest($data);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 21:28:43 -06:00
|
|
|
public function ensureJobResult(array $result): void
|
|
|
|
|
{
|
|
|
|
|
if (!$this->jobManifestPath) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$status = $result['status'] ?? null;
|
|
|
|
|
$progress = $this->getProgress();
|
|
|
|
|
|
|
|
|
|
if ($status === 'success') {
|
|
|
|
|
$targetVersion = $result['version'] ?? ($result['manifest']['target_version'] ?? null);
|
|
|
|
|
$manifest = $result['manifest'] ?? null;
|
|
|
|
|
|
|
|
|
|
if (($progress['stage'] ?? null) !== 'complete') {
|
|
|
|
|
$extras = [];
|
|
|
|
|
if ($targetVersion !== null) {
|
|
|
|
|
$extras['target_version'] = $targetVersion;
|
|
|
|
|
}
|
|
|
|
|
if ($manifest !== null) {
|
|
|
|
|
$extras['manifest'] = $manifest;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->setProgress('complete', 'Upgrade complete.', 100, $extras);
|
|
|
|
|
$progress = $this->getProgress();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->updateJob([
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'completed_at' => time(),
|
|
|
|
|
'result' => $result,
|
|
|
|
|
'progress' => $progress,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($status === 'error') {
|
|
|
|
|
$message = $result['message'] ?? 'Safe upgrade failed.';
|
|
|
|
|
if (($progress['stage'] ?? null) !== 'error') {
|
|
|
|
|
$this->setProgress('error', $message, null, ['message' => $message]);
|
|
|
|
|
$progress = $this->getProgress();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->updateJob([
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'completed_at' => time(),
|
|
|
|
|
'result' => $result,
|
|
|
|
|
'progress' => $progress,
|
|
|
|
|
'error' => $message,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($status === 'noop' || $status === 'finalized') {
|
|
|
|
|
if (($progress['stage'] ?? null) !== 'complete') {
|
|
|
|
|
$this->setProgress('complete', $progress['message'] ?? 'Upgrade complete.', 100, [
|
|
|
|
|
'target_version' => $result['version'] ?? null,
|
|
|
|
|
'manifest' => $result['manifest'] ?? null,
|
|
|
|
|
]);
|
|
|
|
|
$progress = $this->getProgress();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->updateJob([
|
|
|
|
|
'status' => $status,
|
|
|
|
|
'completed_at' => time(),
|
|
|
|
|
'result' => $result,
|
|
|
|
|
'progress' => $progress,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 17:31:57 -06:00
|
|
|
public function markJobError(string $message): void
|
|
|
|
|
{
|
|
|
|
|
$this->setProgress('error', $message, null, ['message' => $message]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function readManifest(?string $path = null): array
|
|
|
|
|
{
|
|
|
|
|
$target = $path ?? $this->jobManifestPath;
|
|
|
|
|
if (!$target || !is_file($target)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decoded = json_decode((string)file_get_contents($target), true);
|
|
|
|
|
|
|
|
|
|
return is_array($decoded) ? $decoded : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function loadJob(string $jobId): array
|
|
|
|
|
{
|
|
|
|
|
$this->setJobId($jobId);
|
|
|
|
|
|
|
|
|
|
return $this->readManifest();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getJobStatus(string $jobId): array
|
|
|
|
|
{
|
|
|
|
|
$manifest = $this->loadJob($jobId);
|
|
|
|
|
$progress = $this->getProgress();
|
|
|
|
|
|
|
|
|
|
$result = [
|
|
|
|
|
'job' => $manifest ?: null,
|
|
|
|
|
'progress' => $progress,
|
2025-10-17 10:02:22 -06:00
|
|
|
'context' => $this->buildStatusContext(),
|
2025-10-16 17:31:57 -06:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$this->clearJobContext();
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function queue(array $options = []): array
|
|
|
|
|
{
|
2025-10-18 13:48:40 -06:00
|
|
|
$operation = $options['operation'] ?? 'upgrade';
|
|
|
|
|
$options['operation'] = $operation;
|
|
|
|
|
|
|
|
|
|
$this->resetProgress();
|
2025-10-16 17:31:57 -06:00
|
|
|
$jobId = $this->generateJobId();
|
|
|
|
|
$this->setJobId($jobId);
|
|
|
|
|
|
|
|
|
|
$jobDir = $this->getJobDir($jobId);
|
|
|
|
|
Folder::create($jobDir);
|
|
|
|
|
|
|
|
|
|
$logPath = $jobDir . '/worker.log';
|
|
|
|
|
|
|
|
|
|
$timestamp = time();
|
|
|
|
|
|
|
|
|
|
$manifest = [
|
|
|
|
|
'id' => $jobId,
|
|
|
|
|
'status' => 'queued',
|
|
|
|
|
'options' => $options,
|
|
|
|
|
'log' => $logPath,
|
|
|
|
|
'created_at' => $timestamp,
|
|
|
|
|
'started_at' => null,
|
|
|
|
|
'completed_at' => null,
|
|
|
|
|
];
|
|
|
|
|
$this->writeManifest($manifest);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
file_put_contents($logPath, '[' . gmdate('c') . "] Job {$jobId} queued\n");
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
// ignore log write failures
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 17:59:05 -06:00
|
|
|
$this->log(sprintf('Queued safe upgrade job %s', $jobId));
|
|
|
|
|
|
2025-10-18 13:48:40 -06:00
|
|
|
$queueMessage = $operation === 'restore' ? 'Waiting for restore worker...' : 'Waiting for upgrade worker...';
|
|
|
|
|
$this->setProgress('queued', $queueMessage, 0, ['job_id' => $jobId, 'status' => 'queued', 'operation' => $operation]);
|
2025-10-16 17:31:57 -06:00
|
|
|
|
|
|
|
|
if (!function_exists('proc_open')) {
|
|
|
|
|
$message = 'proc_open() is disabled on this server; unable to run safe upgrade worker.';
|
|
|
|
|
$this->writeManifest([
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'error' => $message,
|
|
|
|
|
]);
|
2025-10-18 13:48:40 -06:00
|
|
|
$this->setProgress('error', $message, null, ['job_id' => $jobId, 'operation' => $operation]);
|
2025-10-16 17:31:57 -06:00
|
|
|
$this->clearJobContext();
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => $message,
|
2025-10-18 13:48:40 -06:00
|
|
|
'operation' => $operation,
|
2025-10-16 17:31:57 -06:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-16 17:59:05 -06:00
|
|
|
$finder = new PhpExecutableFinder();
|
|
|
|
|
$phpPath = $finder->find(false) ?: PHP_BINARY;
|
|
|
|
|
if (!$phpPath) {
|
|
|
|
|
throw new RuntimeException('Unable to locate PHP CLI to start safe upgrade worker.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$gravPath = Utils::isWindows()
|
|
|
|
|
? GRAV_ROOT . '\\bin\\grav'
|
|
|
|
|
: GRAV_ROOT . '/bin/grav';
|
|
|
|
|
|
|
|
|
|
if (!is_file($gravPath)) {
|
|
|
|
|
throw new RuntimeException('Unable to locate Grav CLI binary.');
|
|
|
|
|
}
|
2025-10-16 17:31:57 -06:00
|
|
|
|
|
|
|
|
if (Utils::isWindows()) {
|
|
|
|
|
$commandLine = sprintf(
|
|
|
|
|
'start /B "" %s %s safe-upgrade:run --job=%s >> %s 2>&1',
|
2025-10-16 17:59:05 -06:00
|
|
|
escapeshellarg($phpPath),
|
|
|
|
|
escapeshellarg($gravPath),
|
|
|
|
|
escapeshellarg($jobId),
|
|
|
|
|
escapeshellarg($logPath)
|
2025-10-16 17:31:57 -06:00
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
$commandLine = sprintf(
|
2025-10-16 17:59:05 -06:00
|
|
|
'nohup %s %s safe-upgrade:run --job=%s >> %s 2>&1 &',
|
|
|
|
|
escapeshellarg($phpPath),
|
|
|
|
|
escapeshellarg($gravPath),
|
|
|
|
|
escapeshellarg($jobId),
|
|
|
|
|
escapeshellarg($logPath)
|
2025-10-16 17:31:57 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 17:59:05 -06:00
|
|
|
try {
|
|
|
|
|
file_put_contents($logPath, '[' . gmdate('c') . "] Command: {$commandLine}\n", FILE_APPEND);
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
// ignore log write failures
|
2025-10-16 17:31:57 -06:00
|
|
|
}
|
|
|
|
|
|
2025-10-16 17:59:05 -06:00
|
|
|
$this->log(sprintf('Spawn command for job %s: %s', $jobId, $commandLine), 'debug');
|
2025-10-16 17:31:57 -06:00
|
|
|
|
2025-10-16 17:59:05 -06:00
|
|
|
$process = Process::fromShellCommandline($commandLine, GRAV_ROOT, null, null, 3);
|
|
|
|
|
$process->disableOutput();
|
|
|
|
|
$process->run();
|
2025-10-16 17:31:57 -06:00
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
$message = $e->getMessage();
|
|
|
|
|
$this->writeManifest([
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'error' => $message,
|
|
|
|
|
]);
|
2025-10-18 13:48:40 -06:00
|
|
|
$this->setProgress('error', $message, null, ['job_id' => $jobId, 'operation' => $operation]);
|
2025-10-16 17:31:57 -06:00
|
|
|
$this->clearJobContext();
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => $message,
|
2025-10-18 13:48:40 -06:00
|
|
|
'operation' => $operation,
|
2025-10-16 17:31:57 -06:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->writeManifest([
|
|
|
|
|
'status' => 'running',
|
|
|
|
|
'started_at' => time(),
|
|
|
|
|
]);
|
|
|
|
|
|
2025-10-16 17:59:05 -06:00
|
|
|
$this->log(sprintf('Safe upgrade job %s worker started', $jobId));
|
|
|
|
|
|
2025-10-16 17:31:57 -06:00
|
|
|
return [
|
|
|
|
|
'status' => 'queued',
|
|
|
|
|
'job_id' => $jobId,
|
|
|
|
|
'log' => $logPath,
|
|
|
|
|
'progress' => $this->getProgress(),
|
|
|
|
|
'job' => $this->readManifest(),
|
2025-10-17 10:02:22 -06:00
|
|
|
'context' => $this->buildStatusContext(),
|
2025-10-18 13:48:40 -06:00
|
|
|
'operation' => $operation,
|
2025-10-16 17:31:57 -06:00
|
|
|
];
|
2025-10-16 10:59:50 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Execute preflight checks and return upgrade readiness data.
|
|
|
|
|
*
|
|
|
|
|
* @param bool $force
|
|
|
|
|
* @return array
|
|
|
|
|
*/
|
|
|
|
|
public function preflight(bool $force = false): array
|
|
|
|
|
{
|
|
|
|
|
$this->resetProgress();
|
|
|
|
|
|
|
|
|
|
if (!class_exists(ZipArchive::class)) {
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => 'php-zip extension needs to be enabled.',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$this->upgrader = new Upgrader($force);
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => $e->getMessage(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$local = $this->upgrader->getLocalVersion();
|
|
|
|
|
$remote = $this->upgrader->getRemoteVersion();
|
|
|
|
|
$releaseDate = $this->upgrader->getReleaseDate();
|
|
|
|
|
$assets = $this->upgrader->getAssets();
|
2025-10-16 20:57:46 -06:00
|
|
|
$package = $this->resolveAsset($assets, 'grav-update');
|
2025-10-16 10:59:50 -06:00
|
|
|
|
|
|
|
|
$payload = [
|
|
|
|
|
'status' => 'ready',
|
|
|
|
|
'version' => [
|
|
|
|
|
'local' => $local,
|
|
|
|
|
'remote' => $remote,
|
|
|
|
|
'release_date' => $releaseDate ? strftime('%c', strtotime($releaseDate)) : null,
|
|
|
|
|
'package_size' => $package['size'] ?? null,
|
|
|
|
|
],
|
|
|
|
|
'upgrade_available' => $this->upgrader->isUpgradable(),
|
|
|
|
|
'requirements' => [
|
|
|
|
|
'meets' => $this->upgrader->meetsRequirements(),
|
|
|
|
|
'minimum_php' => $this->upgrader->minPHPVersion(),
|
|
|
|
|
],
|
|
|
|
|
'symlinked' => false,
|
|
|
|
|
'safe_upgrade' => [
|
|
|
|
|
'enabled' => $this->isSafeUpgradeEnabled(),
|
|
|
|
|
'staging_ready' => true,
|
|
|
|
|
'error' => null,
|
|
|
|
|
],
|
|
|
|
|
'preflight' => [
|
|
|
|
|
'warnings' => [],
|
|
|
|
|
'plugins_pending' => [],
|
|
|
|
|
'psr_log_conflicts' => [],
|
|
|
|
|
'monolog_conflicts' => [],
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
Installer::isValidDestination(GRAV_ROOT . '/system');
|
|
|
|
|
$payload['symlinked'] = Installer::IS_LINK === Installer::lastErrorCode();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$safeUpgrade = $this->getSafeUpgradeService();
|
|
|
|
|
$payload['preflight'] = $safeUpgrade->preflight();
|
|
|
|
|
} catch (RuntimeException $e) {
|
|
|
|
|
$payload['safe_upgrade']['staging_ready'] = false;
|
|
|
|
|
$payload['safe_upgrade']['error'] = $e->getMessage();
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
$payload['safe_upgrade']['staging_ready'] = false;
|
|
|
|
|
$payload['safe_upgrade']['error'] = $e->getMessage();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $payload;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run the safe upgrade lifecycle.
|
|
|
|
|
*
|
|
|
|
|
* @param array $options
|
|
|
|
|
* @return array
|
|
|
|
|
*/
|
|
|
|
|
public function run(array $options = []): array
|
|
|
|
|
{
|
2025-10-18 17:54:29 -06:00
|
|
|
$operation = isset($options['operation']) ? (string)$options['operation'] : 'upgrade';
|
|
|
|
|
if ($operation === 'restore') {
|
|
|
|
|
return $this->runRestore($options);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 10:59:50 -06:00
|
|
|
$force = (bool)($options['force'] ?? false);
|
|
|
|
|
$timeout = (int)($options['timeout'] ?? 30);
|
|
|
|
|
$overwrite = (bool)($options['overwrite'] ?? false);
|
|
|
|
|
$decisions = is_array($options['decisions'] ?? null) ? $options['decisions'] : [];
|
|
|
|
|
|
|
|
|
|
$this->setProgress('initializing', 'Preparing safe upgrade...', null);
|
|
|
|
|
|
|
|
|
|
if (!class_exists(ZipArchive::class)) {
|
|
|
|
|
return $this->errorResult('php-zip extension needs to be enabled.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$this->upgrader = new Upgrader($force);
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
return $this->errorResult($e->getMessage());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$safeUpgradeEnabled = $this->isSafeUpgradeEnabled();
|
|
|
|
|
if (!$safeUpgradeEnabled) {
|
|
|
|
|
return $this->errorResult('Safe upgrade is disabled in configuration.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$remoteVersion = $this->upgrader->getRemoteVersion();
|
|
|
|
|
$localVersion = $this->upgrader->getLocalVersion();
|
|
|
|
|
|
|
|
|
|
if (!$this->upgrader->meetsRequirements()) {
|
|
|
|
|
$minPhp = $this->upgrader->minPHPVersion();
|
|
|
|
|
$message = sprintf(
|
|
|
|
|
'Grav requires PHP %s, current PHP version is %s.',
|
|
|
|
|
$minPhp,
|
|
|
|
|
PHP_VERSION
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return $this->errorResult($message, [
|
|
|
|
|
'minimum_php' => $minPhp,
|
|
|
|
|
'current_php' => PHP_VERSION,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$overwrite && !$this->upgrader->isUpgradable()) {
|
|
|
|
|
$result = $this->runFinalizeIfNeeded($localVersion);
|
|
|
|
|
if ($result) {
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'noop',
|
|
|
|
|
'version' => $localVersion,
|
|
|
|
|
'message' => 'Grav is already up to date.',
|
2025-10-17 10:02:22 -06:00
|
|
|
'context' => $this->buildStatusContext(),
|
2025-10-16 10:59:50 -06:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Installer::isValidDestination(GRAV_ROOT . '/system');
|
|
|
|
|
if (Installer::IS_LINK === Installer::lastErrorCode()) {
|
|
|
|
|
return $this->errorResult('Grav installation is symlinked, cannot perform upgrade.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$safeUpgrade = $this->getSafeUpgradeService();
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
return $this->errorResult($e->getMessage());
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 15:47:33 -06:00
|
|
|
if (defined('Monolog\\Logger::API') && \Monolog\Logger::API < 3) {
|
|
|
|
|
class_exists(\Monolog\Logger::class);
|
|
|
|
|
class_exists(\Monolog\Handler\AbstractHandler::class);
|
|
|
|
|
class_exists(\Monolog\Handler\AbstractProcessingHandler::class);
|
|
|
|
|
class_exists(\Monolog\Handler\StreamHandler::class);
|
|
|
|
|
class_exists(\Monolog\Formatter\LineFormatter::class);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 10:59:50 -06:00
|
|
|
$preflight = $safeUpgrade->preflight();
|
|
|
|
|
if (!empty($preflight['plugins_pending'])) {
|
|
|
|
|
return $this->errorResult('Plugins and/or themes require updates before upgrading Grav.', [
|
|
|
|
|
'plugins_pending' => $preflight['plugins_pending'],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$conflictError = $this->handleConflictDecisions($preflight, $decisions);
|
|
|
|
|
if ($conflictError !== null) {
|
|
|
|
|
return $conflictError;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$assets = $this->upgrader->getAssets();
|
2025-10-16 20:57:46 -06:00
|
|
|
$package = $this->resolveAsset($assets, 'grav-update');
|
2025-10-16 10:59:50 -06:00
|
|
|
if (!$package) {
|
|
|
|
|
return $this->errorResult('Unable to locate Grav update package information.');
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 13:49:53 -06:00
|
|
|
if ($this->recovery && method_exists($this->recovery, 'markUpgradeWindow')) {
|
|
|
|
|
// Newer Grav exposes upgrade window helpers; guard for older cores.
|
|
|
|
|
$this->recovery->markUpgradeWindow('core-upgrade', [
|
|
|
|
|
'scope' => 'core',
|
|
|
|
|
'target_version' => $remoteVersion,
|
|
|
|
|
]);
|
|
|
|
|
}
|
2025-10-16 10:59:50 -06:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$file = $this->download($package, $timeout);
|
|
|
|
|
$this->performInstall($file);
|
2025-10-17 14:09:56 -06:00
|
|
|
$this->setProgress('installing', 'Preparing promotion...', null);
|
2025-10-16 10:59:50 -06:00
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
$this->setProgress('error', $e->getMessage(), null);
|
|
|
|
|
|
|
|
|
|
return $this->errorResult($e->getMessage());
|
|
|
|
|
} finally {
|
|
|
|
|
if ($this->tmp && is_dir($this->tmp)) {
|
|
|
|
|
Folder::delete($this->tmp);
|
|
|
|
|
}
|
|
|
|
|
$this->tmp = null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 14:09:56 -06:00
|
|
|
$this->setProgress('finalizing', 'Finalizing upgrade...', null);
|
2025-10-16 10:59:50 -06:00
|
|
|
$safeUpgrade->clearRecoveryFlag();
|
2025-10-16 13:49:53 -06:00
|
|
|
if ($this->recovery && method_exists($this->recovery, 'closeUpgradeWindow')) {
|
|
|
|
|
$this->recovery->closeUpgradeWindow();
|
|
|
|
|
}
|
2025-10-16 10:59:50 -06:00
|
|
|
|
2025-10-16 18:16:27 -06:00
|
|
|
$this->ensureExecutablePermissions();
|
2025-10-17 14:09:56 -06:00
|
|
|
$this->setProgress('finalizing', 'Finalizing upgrade...', null);
|
2025-10-16 18:16:27 -06:00
|
|
|
|
2025-10-16 10:59:50 -06:00
|
|
|
$manifest = $this->resolveLatestManifest();
|
|
|
|
|
|
|
|
|
|
$this->setProgress('complete', 'Upgrade complete.', 100, [
|
|
|
|
|
'target_version' => $remoteVersion,
|
|
|
|
|
'manifest' => $manifest,
|
|
|
|
|
]);
|
|
|
|
|
|
2025-10-16 17:31:57 -06:00
|
|
|
if ($this->jobManifestPath) {
|
|
|
|
|
$this->updateJob([
|
|
|
|
|
'result' => [
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'version' => $remoteVersion,
|
|
|
|
|
'previous_version' => $localVersion,
|
|
|
|
|
'manifest' => $manifest,
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 10:59:50 -06:00
|
|
|
return [
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'version' => $remoteVersion,
|
|
|
|
|
'manifest' => $manifest,
|
|
|
|
|
'previous_version' => $localVersion,
|
2025-10-17 10:02:22 -06:00
|
|
|
'context' => $this->buildStatusContext(),
|
2025-10-16 10:59:50 -06:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-18 13:48:40 -06:00
|
|
|
public function runRestore(array $options): array
|
|
|
|
|
{
|
|
|
|
|
$snapshotId = isset($options['snapshot_id']) ? (string)$options['snapshot_id'] : '';
|
|
|
|
|
if ($snapshotId === '') {
|
|
|
|
|
return $this->errorResult('Snapshot identifier is required.', ['operation' => 'restore']);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-18 17:54:29 -06:00
|
|
|
$this->setProgress('rollback', sprintf('Restoring snapshot %s...', $snapshotId), null, [
|
2025-10-18 13:48:40 -06:00
|
|
|
'operation' => 'restore',
|
|
|
|
|
'snapshot' => $snapshotId,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->restoreSnapshot($snapshotId);
|
|
|
|
|
if (($result['status'] ?? 'error') !== 'success') {
|
|
|
|
|
$message = $result['message'] ?? 'Snapshot restore failed.';
|
|
|
|
|
|
|
|
|
|
return $this->errorResult($message, [
|
|
|
|
|
'operation' => 'restore',
|
|
|
|
|
'snapshot' => $snapshotId,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$manifest = $result['manifest'] ?? [];
|
|
|
|
|
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? null;
|
|
|
|
|
|
|
|
|
|
$this->setProgress('complete', sprintf('Snapshot %s restored.', $snapshotId), 100, [
|
|
|
|
|
'operation' => 'restore',
|
|
|
|
|
'snapshot' => $snapshotId,
|
|
|
|
|
'version' => $version,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($this->jobManifestPath) {
|
|
|
|
|
$this->updateJob([
|
|
|
|
|
'result' => [
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'snapshot' => $snapshotId,
|
|
|
|
|
'version' => $version,
|
|
|
|
|
'manifest' => $manifest,
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'snapshot' => $snapshotId,
|
|
|
|
|
'version' => $version,
|
|
|
|
|
'manifest' => $manifest,
|
|
|
|
|
'context' => $this->buildStatusContext(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 10:59:50 -06:00
|
|
|
/**
|
|
|
|
|
* Retrieve current progress payload.
|
|
|
|
|
*
|
|
|
|
|
* @return array
|
|
|
|
|
*/
|
|
|
|
|
public function getProgress(): array
|
|
|
|
|
{
|
|
|
|
|
if (!is_file($this->progressPath)) {
|
|
|
|
|
return [
|
|
|
|
|
'stage' => 'idle',
|
|
|
|
|
'message' => '',
|
|
|
|
|
'percent' => null,
|
|
|
|
|
'timestamp' => time(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decoded = json_decode((string)file_get_contents($this->progressPath), true);
|
|
|
|
|
if (!is_array($decoded)) {
|
|
|
|
|
return [
|
|
|
|
|
'stage' => 'idle',
|
|
|
|
|
'message' => '',
|
|
|
|
|
'percent' => null,
|
|
|
|
|
'timestamp' => time(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $decoded;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reset progress file to idle state.
|
|
|
|
|
*
|
|
|
|
|
* @return void
|
|
|
|
|
*/
|
|
|
|
|
public function resetProgress(): void
|
|
|
|
|
{
|
|
|
|
|
$this->setProgress('idle', '', null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return SafeUpgradeService
|
|
|
|
|
*/
|
|
|
|
|
protected function getSafeUpgradeService(): SafeUpgradeService
|
|
|
|
|
{
|
|
|
|
|
if ($this->safeUpgrade instanceof SafeUpgradeService) {
|
|
|
|
|
return $this->safeUpgrade;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$config = null;
|
|
|
|
|
try {
|
|
|
|
|
$config = $this->grav['config'] ?? null;
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
$config = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->safeUpgrade = new SafeUpgradeService([
|
2025-10-17 16:17:09 -06:00
|
|
|
'config' => $config,
|
2025-10-16 10:59:50 -06:00
|
|
|
]);
|
2025-10-17 18:19:22 -06:00
|
|
|
if (method_exists($this->safeUpgrade, 'setProgressCallback')) {
|
2025-10-17 18:23:17 -06:00
|
|
|
$this->safeUpgrade->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) {
|
2025-10-17 18:19:22 -06:00
|
|
|
$this->setProgress($stage, $message, $percent);
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-16 10:59:50 -06:00
|
|
|
|
|
|
|
|
return $this->safeUpgrade;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return bool
|
|
|
|
|
*/
|
|
|
|
|
protected function isSafeUpgradeEnabled(): bool
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
$config = $this->grav['config'] ?? null;
|
|
|
|
|
if ($config === null) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (bool)$config->get('system.updates.safe_upgrade', true);
|
|
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array $preflight
|
|
|
|
|
* @param array $decisions
|
|
|
|
|
* @return array|null
|
|
|
|
|
*/
|
|
|
|
|
protected function handleConflictDecisions(array $preflight, array $decisions): ?array
|
|
|
|
|
{
|
|
|
|
|
$psrConflicts = $preflight['psr_log_conflicts'] ?? [];
|
|
|
|
|
$monologConflicts = $preflight['monolog_conflicts'] ?? [];
|
|
|
|
|
|
|
|
|
|
if ($psrConflicts) {
|
|
|
|
|
$decision = $decisions['psr_log'] ?? null;
|
|
|
|
|
$error = $this->applyConflictDecision(
|
|
|
|
|
$decision,
|
|
|
|
|
$psrConflicts,
|
|
|
|
|
'Disabled before upgrade because of psr/log conflict'
|
|
|
|
|
);
|
|
|
|
|
if ($error !== null) {
|
|
|
|
|
return $error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($monologConflicts) {
|
|
|
|
|
$decision = $decisions['monolog'] ?? null;
|
|
|
|
|
$error = $this->applyConflictDecision(
|
|
|
|
|
$decision,
|
|
|
|
|
$monologConflicts,
|
|
|
|
|
'Disabled before upgrade because of Monolog API conflict'
|
|
|
|
|
);
|
|
|
|
|
if ($error !== null) {
|
|
|
|
|
return $error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string|null $decision
|
|
|
|
|
* @param array $conflicts
|
|
|
|
|
* @param string $disableNote
|
|
|
|
|
* @return array|null
|
|
|
|
|
*/
|
|
|
|
|
protected function applyConflictDecision(?string $decision, array $conflicts, string $disableNote): ?array
|
|
|
|
|
{
|
|
|
|
|
if (!$conflicts) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$choice = $decision ?: 'abort';
|
|
|
|
|
if ($choice === 'abort') {
|
|
|
|
|
return $this->errorResult('Upgrade aborted due to unresolved conflicts.', [
|
|
|
|
|
'conflicts' => $conflicts,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($choice === 'disable') {
|
|
|
|
|
foreach (array_keys($conflicts) as $slug) {
|
|
|
|
|
$this->recovery->disablePlugin($slug, ['message' => $disableNote]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($choice === 'continue') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->errorResult('Unknown conflict decision provided.', [
|
|
|
|
|
'conflicts' => $conflicts,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array $package
|
|
|
|
|
* @param int $timeout
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
protected function download(array $package, int $timeout): string
|
|
|
|
|
{
|
|
|
|
|
$tmpDir = $this->grav['locator']->findResource('tmp://', true, true);
|
|
|
|
|
$this->tmp = $tmpDir . '/grav-update-' . uniqid('', false);
|
|
|
|
|
|
|
|
|
|
Folder::create($this->tmp);
|
|
|
|
|
|
|
|
|
|
$this->setProgress('downloading', 'Downloading update...', 0, [
|
|
|
|
|
'package_size' => $package['size'] ?? null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$options = [
|
|
|
|
|
'timeout' => max(0, $timeout),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$progressCallback = function (array $progress): void {
|
|
|
|
|
$this->setProgress('downloading', 'Downloading update...', $progress['percent'], [
|
|
|
|
|
'transferred' => $progress['transferred'],
|
|
|
|
|
'filesize' => $progress['filesize'],
|
|
|
|
|
]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
$output = Response::get($package['download'], $options, $progressCallback);
|
|
|
|
|
|
|
|
|
|
$this->setProgress('downloading', 'Download complete.', 100);
|
|
|
|
|
|
|
|
|
|
$target = $this->tmp . '/' . $package['name'];
|
|
|
|
|
file_put_contents($target, $output);
|
|
|
|
|
|
|
|
|
|
return $target;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param string $zip
|
|
|
|
|
* @return void
|
|
|
|
|
*/
|
|
|
|
|
protected function performInstall(string $zip): void
|
|
|
|
|
{
|
2025-10-17 14:09:56 -06:00
|
|
|
$this->setProgress('installing', 'Unpacking update...', null);
|
2025-10-16 10:59:50 -06:00
|
|
|
$folder = Installer::unZip($zip, $this->tmp . '/zip');
|
|
|
|
|
if ($folder === false) {
|
|
|
|
|
throw new RuntimeException(Installer::lastErrorMsg());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$script = $folder . '/system/install.php';
|
|
|
|
|
if (!file_exists($script)) {
|
|
|
|
|
throw new RuntimeException('Downloaded archive is not a valid Grav package.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$install = include $script;
|
|
|
|
|
if (!is_callable($install)) {
|
|
|
|
|
throw new RuntimeException('Unable to bootstrap installer from downloaded package.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-10-17 14:09:56 -06:00
|
|
|
$this->setProgress('installing', 'Running installer...', null);
|
2025-10-16 10:59:50 -06:00
|
|
|
$install($zip);
|
2025-10-17 14:09:56 -06:00
|
|
|
$this->setProgress('installing', 'Verifying files...', null);
|
2025-10-16 10:59:50 -06:00
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
throw new RuntimeException($e->getMessage(), 0, $e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$errorCode = Installer::lastErrorCode();
|
|
|
|
|
if ($errorCode) {
|
|
|
|
|
throw new RuntimeException(Installer::lastErrorMsg());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Attempt to run finalize scripts if Grav is already up to date but schema mismatched.
|
|
|
|
|
*
|
|
|
|
|
* @param string $localVersion
|
|
|
|
|
* @return array|null
|
|
|
|
|
*/
|
|
|
|
|
protected function runFinalizeIfNeeded(string $localVersion): ?array
|
|
|
|
|
{
|
|
|
|
|
$config = $this->grav['config'];
|
|
|
|
|
$schema = $config->get('versions.core.grav.schema');
|
|
|
|
|
|
|
|
|
|
if ($schema !== GRAV_SCHEMA && version_compare((string)$schema, GRAV_SCHEMA, '<')) {
|
|
|
|
|
$this->setProgress('finalizing', 'Running post-install scripts...', null);
|
|
|
|
|
Install::instance()->finalize();
|
|
|
|
|
$this->setProgress('complete', 'Post-install scripts executed.', 100, [
|
|
|
|
|
'target_version' => $localVersion,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'finalized',
|
|
|
|
|
'version' => $localVersion,
|
|
|
|
|
'message' => 'Post-install scripts completed.',
|
2025-10-17 10:02:22 -06:00
|
|
|
'context' => $this->buildStatusContext(),
|
2025-10-16 10:59:50 -06:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fetch most recent safe upgrade manifest if available.
|
|
|
|
|
*
|
|
|
|
|
* @return array|null
|
|
|
|
|
*/
|
|
|
|
|
protected function resolveLatestManifest(): ?array
|
|
|
|
|
{
|
|
|
|
|
$store = $this->grav['locator']->findResource('user://data/upgrades', false);
|
|
|
|
|
if (!$store || !is_dir($store)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$files = glob($store . '/*.json') ?: [];
|
|
|
|
|
if (!$files) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
rsort($files);
|
|
|
|
|
|
|
|
|
|
$latest = $files[0];
|
|
|
|
|
$decoded = json_decode(file_get_contents($latest), true);
|
|
|
|
|
|
|
|
|
|
return is_array($decoded) ? $decoded : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Persist progress payload.
|
|
|
|
|
*
|
|
|
|
|
* @param string $stage
|
|
|
|
|
* @param string $message
|
|
|
|
|
* @param int|null $percent
|
|
|
|
|
* @param array $extra
|
|
|
|
|
* @return void
|
|
|
|
|
*/
|
|
|
|
|
protected function setProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void
|
|
|
|
|
{
|
|
|
|
|
$payload = [
|
|
|
|
|
'stage' => $stage,
|
|
|
|
|
'message' => $message,
|
|
|
|
|
'percent' => $percent,
|
|
|
|
|
'timestamp' => time(),
|
|
|
|
|
] + $extra;
|
|
|
|
|
|
2025-10-16 17:31:57 -06:00
|
|
|
if ($this->jobId) {
|
|
|
|
|
$payload['job_id'] = $this->jobId;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 10:59:50 -06:00
|
|
|
try {
|
|
|
|
|
Folder::create(dirname($this->progressPath));
|
|
|
|
|
file_put_contents($this->progressPath, json_encode($payload, JSON_PRETTY_PRINT));
|
2025-10-16 17:59:05 -06:00
|
|
|
if ($this->jobId) {
|
|
|
|
|
$this->log(sprintf('Job %s stage -> %s (%s)', $this->jobId, $stage, $message), $stage === 'error' ? 'error' : 'debug');
|
|
|
|
|
}
|
2025-10-16 10:59:50 -06:00
|
|
|
} catch (Throwable $e) {
|
|
|
|
|
// ignore write failures
|
|
|
|
|
}
|
2025-10-16 17:31:57 -06:00
|
|
|
|
|
|
|
|
if ($this->jobManifestPath) {
|
|
|
|
|
$status = 'running';
|
|
|
|
|
if ($stage === 'error') {
|
|
|
|
|
$status = 'error';
|
|
|
|
|
} elseif ($stage === 'complete') {
|
|
|
|
|
$status = 'success';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$manifest = [
|
|
|
|
|
'status' => $status,
|
|
|
|
|
'progress' => $payload,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if ($status === 'success') {
|
|
|
|
|
$manifest['completed_at'] = time();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($status === 'error' && isset($extra['message'])) {
|
|
|
|
|
$manifest['error'] = $extra['message'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->writeManifest($manifest);
|
|
|
|
|
}
|
2025-10-16 10:59:50 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Helper for building an error result payload.
|
|
|
|
|
*
|
|
|
|
|
* @param string $message
|
|
|
|
|
* @param array $extra
|
|
|
|
|
* @return array
|
|
|
|
|
*/
|
|
|
|
|
protected function errorResult(string $message, array $extra = []): array
|
|
|
|
|
{
|
2025-10-16 17:31:57 -06:00
|
|
|
$extraWithMessage = ['message' => $message] + $extra;
|
|
|
|
|
$this->setProgress('error', $message, null, $extraWithMessage);
|
|
|
|
|
|
|
|
|
|
if ($this->jobManifestPath) {
|
|
|
|
|
$this->updateJob([
|
|
|
|
|
'result' => [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => $message,
|
|
|
|
|
'details' => $extra,
|
|
|
|
|
],
|
2025-10-16 17:59:05 -06:00
|
|
|
'status' => 'error',
|
|
|
|
|
'completed_at' => time(),
|
2025-10-16 17:31:57 -06:00
|
|
|
]);
|
2025-10-16 17:59:05 -06:00
|
|
|
$this->log(sprintf('Safe upgrade job %s failed: %s', $this->jobId ?? 'n/a', $message), 'error');
|
2025-10-16 17:31:57 -06:00
|
|
|
}
|
2025-10-16 10:59:50 -06:00
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'status' => 'error',
|
|
|
|
|
'message' => $message,
|
2025-10-17 10:02:22 -06:00
|
|
|
'context' => $this->buildStatusContext(),
|
2025-10-16 10:59:50 -06:00
|
|
|
] + $extra;
|
|
|
|
|
}
|
2025-10-16 18:16:27 -06:00
|
|
|
|
2025-10-17 10:02:22 -06:00
|
|
|
protected function buildStatusContext(): ?string
|
|
|
|
|
{
|
|
|
|
|
$context = [];
|
|
|
|
|
|
|
|
|
|
if ($this->jobManifestPath) {
|
2025-10-17 11:31:02 -06:00
|
|
|
$context['manifest'] = $this->convertPathForContext($this->jobManifestPath);
|
2025-10-17 10:02:22 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($this->progressPath) {
|
2025-10-17 11:31:02 -06:00
|
|
|
$context['progress'] = $this->convertPathForContext($this->progressPath);
|
2025-10-17 10:02:22 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$context) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$encoded = json_encode($context);
|
|
|
|
|
|
|
|
|
|
return $encoded === false ? null : base64_encode($encoded);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 11:31:02 -06:00
|
|
|
private function convertPathForContext(string $path): string
|
|
|
|
|
{
|
|
|
|
|
$normalized = str_replace('\\', '/', $path);
|
|
|
|
|
$root = str_replace('\\', '/', GRAV_ROOT);
|
|
|
|
|
|
|
|
|
|
if (strpos($normalized, $root) === 0) {
|
|
|
|
|
$relative = substr($normalized, strlen($root));
|
|
|
|
|
|
|
|
|
|
return ltrim($relative, '/');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $normalized;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 18:16:27 -06:00
|
|
|
protected function ensureExecutablePermissions(): void
|
|
|
|
|
{
|
|
|
|
|
$executables = [
|
|
|
|
|
'bin/grav',
|
|
|
|
|
'bin/plugin',
|
|
|
|
|
'bin/gpm',
|
|
|
|
|
'bin/restore',
|
|
|
|
|
'bin/composer.phar'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($executables as $relative) {
|
|
|
|
|
$path = GRAV_ROOT . '/' . $relative;
|
|
|
|
|
if (!is_file($path) || is_link($path)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$perms = @fileperms($path);
|
|
|
|
|
$mode = $perms !== false ? ($perms & 0777) : null;
|
|
|
|
|
if ($mode !== 0755) {
|
|
|
|
|
@chmod($path, 0755);
|
|
|
|
|
$this->log(sprintf('Adjusted permissions on %s to 0755', $relative), 'debug');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-16 20:57:46 -06:00
|
|
|
|
|
|
|
|
protected function resolveAsset(array $assets, string $prefix): ?array
|
|
|
|
|
{
|
|
|
|
|
if (isset($assets[$prefix])) {
|
|
|
|
|
return $assets[$prefix];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($assets as $key => $asset) {
|
|
|
|
|
$name = is_array($asset) ? ($asset['name'] ?? '') : '';
|
|
|
|
|
$haystack = $key . ' ' . $name;
|
|
|
|
|
if (stripos($haystack, $prefix) === 0 || stripos($haystack, '/' . $prefix) !== false) {
|
|
|
|
|
return $asset;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-10-16 10:59:50 -06:00
|
|
|
}
|