Files
Grav-Admin-Plugin/classes/plugin/SafeUpgradeManager.php

1086 lines
32 KiB
PHP
Raw Normal View History

<?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;
use Grav\Common\Utils;
use Grav\Installer\Install;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
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';
private const JOB_MANIFEST = 'manifest.json';
private const JOB_PROGRESS = 'progress.json';
/** @var Grav */
private $grav;
/** @var Upgrader|null */
private $upgrader;
/** @var SafeUpgradeService|null */
private $safeUpgrade;
/** @var RecoveryManager */
private $recovery;
/** @var string */
private $progressDir;
/** @var string */
private $jobsDir;
/** @var \Psr\Log\LoggerInterface|null */
private $logger;
/** @var string|null */
private $jobId;
/** @var string|null */
private $jobManifestPath;
/** @var string */
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'];
$this->logger = $this->grav['log'] ?? null;
$locator = $this->grav['locator'];
$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;
$this->log(sprintf('Safe upgrade job %s activated', $this->jobId), 'debug');
} else {
$this->jobManifestPath = null;
$this->progressPath = $this->progressDir . '/' . self::PROGRESS_FILENAME;
$this->log('Safe upgrade job context cleared', 'debug');
}
}
public function clearJobContext(): void
{
$this->setJobId(null);
}
protected function getJobDir(string $jobId): string
{
return $this->jobsDir . '/' . $jobId;
}
protected function generateJobId(): string
{
return 'job-' . gmdate('YmdHis') . '-' . substr(md5(uniqid('', true)), 0, 8);
}
protected function log(string $message, string $level = 'info'): void
{
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
}
}
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));
if (!empty($data['status'])) {
$this->log(sprintf('Job %s status -> %s', $payload['id'] ?? $this->jobId ?? 'unknown', $data['status']), 'debug');
}
} 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,
]);
}
}
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(),
];
$this->clearJobContext();
return $result;
}
public function queue(array $options = []): array
{
$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
}
$this->log(sprintf('Queued safe upgrade job %s', $jobId));
2025-10-16 19:56:59 -06:00
$this->setProgress('queued', 'Waiting for upgrade worker...', 0, ['job_id' => $jobId, 'status' => 'queued']);
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,
]);
$this->setProgress('error', $message, null, ['job_id' => $jobId]);
$this->clearJobContext();
return [
'status' => 'error',
'message' => $message,
];
}
try {
$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.');
}
if (Utils::isWindows()) {
$commandLine = sprintf(
'start /B "" %s %s safe-upgrade:run --job=%s >> %s 2>&1',
escapeshellarg($phpPath),
escapeshellarg($gravPath),
escapeshellarg($jobId),
escapeshellarg($logPath)
);
} else {
$commandLine = sprintf(
'nohup %s %s safe-upgrade:run --job=%s >> %s 2>&1 &',
escapeshellarg($phpPath),
escapeshellarg($gravPath),
escapeshellarg($jobId),
escapeshellarg($logPath)
);
}
try {
file_put_contents($logPath, '[' . gmdate('c') . "] Command: {$commandLine}\n", FILE_APPEND);
} catch (Throwable $e) {
// ignore log write failures
}
$this->log(sprintf('Spawn command for job %s: %s', $jobId, $commandLine), 'debug');
$process = Process::fromShellCommandline($commandLine, GRAV_ROOT, null, null, 3);
$process->disableOutput();
$process->run();
} catch (Throwable $e) {
$message = $e->getMessage();
$this->writeManifest([
'status' => 'error',
'error' => $message,
]);
$this->setProgress('error', $message, null, ['job_id' => $jobId]);
$this->clearJobContext();
return [
'status' => 'error',
'message' => $message,
];
}
$this->writeManifest([
'status' => 'running',
'started_at' => time(),
]);
$this->log(sprintf('Safe upgrade job %s worker started', $jobId));
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(),
];
}
/**
* 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');
$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
{
$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(),
];
}
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);
}
$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');
if (!$package) {
return $this->errorResult('Unable to locate Grav update package information.');
}
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,
]);
}
try {
$file = $this->download($package, $timeout);
$this->setProgress('installing', 'Installing update...', null);
$this->performInstall($file);
} 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;
}
$this->setProgress('finalizing', 'Finalizing upgrade...', 100);
$safeUpgrade->clearRecoveryFlag();
if ($this->recovery && method_exists($this->recovery, 'closeUpgradeWindow')) {
$this->recovery->closeUpgradeWindow();
}
2025-10-16 18:16:27 -06:00
$this->ensureExecutablePermissions();
$manifest = $this->resolveLatestManifest();
$this->setProgress('complete', 'Upgrade complete.', 100, [
'target_version' => $remoteVersion,
'manifest' => $manifest,
]);
if ($this->jobManifestPath) {
$this->updateJob([
'result' => [
'status' => 'success',
'version' => $remoteVersion,
'previous_version' => $localVersion,
'manifest' => $manifest,
],
]);
}
return [
'status' => 'success',
'version' => $remoteVersion,
'manifest' => $manifest,
'previous_version' => $localVersion,
2025-10-17 10:02:22 -06:00
'context' => $this->buildStatusContext(),
];
}
/**
* 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;
}
$stagingRoot = $config ? $config->get('system.updates.staging_root') : null;
$this->safeUpgrade = new SafeUpgradeService([
'staging_root' => $stagingRoot,
]);
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
{
$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 {
$install($zip);
} 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(),
];
}
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;
if ($this->jobId) {
$payload['job_id'] = $this->jobId;
}
try {
Folder::create(dirname($this->progressPath));
file_put_contents($this->progressPath, json_encode($payload, JSON_PRETTY_PRINT));
if ($this->jobId) {
$this->log(sprintf('Job %s stage -> %s (%s)', $this->jobId, $stage, $message), $stage === 'error' ? 'error' : 'debug');
}
} catch (Throwable $e) {
// ignore write failures
}
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);
}
}
/**
* Helper for building an error result payload.
*
* @param string $message
* @param array $extra
* @return array
*/
protected function errorResult(string $message, array $extra = []): array
{
$extraWithMessage = ['message' => $message] + $extra;
$this->setProgress('error', $message, null, $extraWithMessage);
if ($this->jobManifestPath) {
$this->updateJob([
'result' => [
'status' => 'error',
'message' => $message,
'details' => $extra,
],
'status' => 'error',
'completed_at' => time(),
]);
$this->log(sprintf('Safe upgrade job %s failed: %s', $this->jobId ?? 'n/a', $message), 'error');
}
return [
'status' => 'error',
'message' => $message,
2025-10-17 10:02:22 -06:00
'context' => $this->buildStatusContext(),
] + $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) {
$context['manifest'] = $this->jobManifestPath;
}
if ($this->progressPath) {
$context['progress'] = $this->progressPath;
}
if (!$context) {
return null;
}
$encoded = json_encode($context);
return $encoded === false ? null : base64_encode($encoded);
}
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;
}
}