mirror of
https://github.com/getgrav/grav.git
synced 2025-10-26 07:56:07 +01:00
initial safeupgrade work
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
# v1.7.50
|
||||
## UNRELEASED
|
||||
|
||||
1. [](#new)
|
||||
* Added staged self-upgrade pipeline with manifest snapshots and atomic swaps for Grav core updates.
|
||||
* Introduced recovery mode with token-gated UI, plugin quarantine, and CLI rollback support.
|
||||
* Added `bin/gpm preflight` compatibility scanner and `bin/gpm rollback` utility.
|
||||
|
||||
# v1.7.49.5
|
||||
## 09/10/2025
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ date_default_timezone_set(@date_default_timezone_get());
|
||||
@ini_set('default_charset', 'UTF-8');
|
||||
mb_internal_encoding('UTF-8');
|
||||
|
||||
$recoveryFlag = __DIR__ . '/system/recovery.flag';
|
||||
if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) {
|
||||
require __DIR__ . '/system/recovery.php';
|
||||
return 0;
|
||||
}
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use RocketTheme\Toolbox\Event\Event;
|
||||
|
||||
|
||||
49
system/UPGRADE_PROTOTYPE.md
Normal file
49
system/UPGRADE_PROTOTYPE.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Grav Safe Self-Upgrade Prototype
|
||||
|
||||
This document tracks the design decisions behind the new self-upgrade prototype for Grav 1.8.
|
||||
|
||||
## Goals
|
||||
|
||||
- Prevent in-place mutation of the running Grav tree.
|
||||
- Guarantee a restorable snapshot before any destructive change.
|
||||
- Detect high-risk plugin incompatibilities (eg. `psr/log`) prior to upgrading.
|
||||
- Provide a recovery surface that does not depend on a working Admin plugin.
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
1. **Preflight**
|
||||
- Ensure PHP & extensions satisfy the target release requirements.
|
||||
- Refresh GPM metadata and require all plugins/themes to be on their latest compatible release.
|
||||
- Scan plugin `composer.json` files for dependencies that are known to break under Grav 1.8 (eg. `psr/log` < 3) and surface actionable warnings.
|
||||
2. **Stage**
|
||||
- Download the Grav update archive into a staging area outside the live tree (`{parent}/grav-upgrades/{timestamp}`).
|
||||
- Extract the package, then write a manifest describing the target version, PHP info, and enabled packages.
|
||||
- Snapshot the live `user/` directory and relevant metadata into the same stage folder.
|
||||
3. **Promote**
|
||||
- Switch the installation by renaming the live tree to a rollback folder and promoting the staged tree into place via atomic renames.
|
||||
- Clear caches in the staged tree before promotion.
|
||||
- Run Grav CLI smoke checks (`bin/grav check`) while still holding maintenance state; swap back automatically on failure.
|
||||
4. **Finalize**
|
||||
- Record the manifest under `user/data/upgrades`.
|
||||
- Resume normal traffic by removing the maintenance flag.
|
||||
- Leave the previous tree and manifest available for manual rollback commands.
|
||||
|
||||
## Recovery Mode
|
||||
|
||||
- Introduce a `system/recovery.flag` sentinel written whenever a fatal error occurs during bootstrap or when a promoted release fails validation.
|
||||
- While the flag is present, Grav forces a minimal Recovery UI served outside of Admin, protected by a short-lived signed token.
|
||||
- The Recovery UI lists recent manifests, quarantined plugins, and offers rollback/disabling actions.
|
||||
- Clearing the flag requires either a successful rollback or a full Grav request cycle without fatal errors.
|
||||
|
||||
## CLI Additions
|
||||
|
||||
- `bin/gpm preflight grav@<version>`: runs the same preflight checks without executing the upgrade.
|
||||
- `bin/gpm rollback [<manifest-id>]`: swaps the live tree with a stored rollback snapshot.
|
||||
- Existing `self-upgrade` command now wraps the stage/promote pipeline and respects the snapshot manifest.
|
||||
|
||||
## Open Items
|
||||
|
||||
- Finalize compatibility heuristics (initial pass focuses on `psr/log` and removed logging APIs).
|
||||
- UX polish for the Recovery UI (initial prototype will expose basic actions only).
|
||||
- Decide retention policy for old manifests and snapshots (prototype keeps the most recent three).
|
||||
|
||||
@@ -1598,6 +1598,22 @@ form:
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
updates_section:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.UPDATES_SECTION
|
||||
|
||||
updates.safe_upgrade:
|
||||
type: toggle
|
||||
label: PLUGIN_ADMIN.SAFE_UPGRADE
|
||||
help: PLUGIN_ADMIN.SAFE_UPGRADE_HELP
|
||||
highlight: 1
|
||||
default: true
|
||||
options:
|
||||
1: PLUGIN_ADMIN.YES
|
||||
0: PLUGIN_ADMIN.NO
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
http_section:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.HTTP_SECTION
|
||||
@@ -1914,4 +1930,3 @@ form:
|
||||
# type: hidden
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -203,6 +203,9 @@ gpm:
|
||||
releases: stable # Set to either 'stable' or 'testing'
|
||||
official_gpm_only: true # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security
|
||||
|
||||
updates:
|
||||
safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates
|
||||
|
||||
http:
|
||||
method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL
|
||||
enable_proxy: true # Enable proxy server configuration
|
||||
|
||||
@@ -119,3 +119,8 @@ GRAV:
|
||||
ERROR2: Bad number of elements
|
||||
ERROR3: The jquery_element should be set into jqCron settings
|
||||
ERROR4: Unrecognized expression
|
||||
|
||||
PLUGIN_ADMIN:
|
||||
UPDATES_SECTION: Updates
|
||||
SAFE_UPGRADE: Safe self-upgrade
|
||||
SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support.
|
||||
|
||||
181
system/recovery.php
Normal file
181
system/recovery.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
|
||||
if (!\defined('GRAV_ROOT')) {
|
||||
\define('GRAV_ROOT', dirname(__DIR__));
|
||||
}
|
||||
|
||||
session_start([
|
||||
'name' => 'grav-recovery',
|
||||
'cookie_httponly' => true,
|
||||
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'cookie_samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
$manager = new RecoveryManager();
|
||||
$context = $manager->getContext() ?? [];
|
||||
$token = $context['token'] ?? null;
|
||||
$authenticated = $token && isset($_SESSION['grav_recovery_authenticated']) && hash_equals($_SESSION['grav_recovery_authenticated'], $token);
|
||||
$errorMessage = null;
|
||||
$notice = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
if ($action === 'authenticate') {
|
||||
$provided = trim($_POST['token'] ?? '');
|
||||
if ($token && hash_equals($token, $provided)) {
|
||||
$_SESSION['grav_recovery_authenticated'] = $token;
|
||||
header('Location: ' . $_SERVER['REQUEST_URI']);
|
||||
exit;
|
||||
}
|
||||
$errorMessage = 'Invalid recovery token.';
|
||||
} elseif ($authenticated) {
|
||||
$service = new SafeUpgradeService();
|
||||
try {
|
||||
if ($action === 'rollback' && !empty($_POST['manifest'])) {
|
||||
$service->rollback(trim($_POST['manifest']));
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Rollback complete. Please reload Grav.';
|
||||
}
|
||||
if ($action === 'clear-flag') {
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Recovery flag cleared.';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$errorMessage = 'Authentication required.';
|
||||
}
|
||||
}
|
||||
|
||||
$quarantineFile = GRAV_ROOT . '/user/data/upgrades/quarantine.json';
|
||||
$quarantine = [];
|
||||
if (is_file($quarantineFile)) {
|
||||
$decoded = json_decode(file_get_contents($quarantineFile), true);
|
||||
if (is_array($decoded)) {
|
||||
$quarantine = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$manifests = [];
|
||||
if (is_dir($manifestDir)) {
|
||||
$files = glob($manifestDir . '/*.json');
|
||||
if ($files) {
|
||||
rsort($files);
|
||||
foreach ($files as $file) {
|
||||
$decoded = json_decode(file_get_contents($file), true);
|
||||
if (is_array($decoded)) {
|
||||
$decoded['file'] = basename($file);
|
||||
$manifests[] = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
?><!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Grav Recovery Mode</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 40px; background: #111; color: #eee; }
|
||||
.panel { max-width: 720px; margin: 0 auto; background: #1d1d1f; padding: 24px 32px; border-radius: 12px; box-shadow: 0 10px 45px rgba(0,0,0,0.4); }
|
||||
h1 { margin-top: 0; color: #9ef; }
|
||||
code { background: rgba(255,255,255,0.08); padding: 2px 4px; border-radius: 4px; }
|
||||
form { margin-top: 16px; }
|
||||
input[type="text"] { width: 100%; padding: 10px; border: 1px solid #333; border-radius: 6px; background: #151517; color: #fff; }
|
||||
button { margin-top: 12px; padding: 10px 16px; border: 0; border-radius: 6px; cursor: pointer; background: #3c8bff; color: #fff; font-weight: 600; }
|
||||
button.secondary { background: #444; }
|
||||
.message { padding: 10px 14px; border-radius: 6px; margin-top: 12px; }
|
||||
.error { background: rgba(220, 53, 69, 0.15); color: #ffb3b8; }
|
||||
.notice { background: rgba(25, 135, 84, 0.2); color: #bdf8d4; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin-bottom: 8px; }
|
||||
.card { border: 1px solid #2a2a2d; border-radius: 8px; padding: 14px 16px; margin-top: 16px; background: #161618; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<h1>Grav Recovery Mode</h1>
|
||||
<?php if ($notice): ?>
|
||||
<div class="message notice"><?php echo htmlspecialchars($notice, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($errorMessage): ?>
|
||||
<div class="message error"><?php echo htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$authenticated): ?>
|
||||
<p>This site is running in recovery mode because Grav detected a fatal error.</p>
|
||||
<p>Locate the recovery token in <code>system/recovery.flag</code> and enter it below.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="authenticate">
|
||||
<label for="token">Recovery token</label>
|
||||
<input id="token" name="token" type="text" autocomplete="one-time-code" required>
|
||||
<button type="submit">Unlock Recovery</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="card">
|
||||
<h2>Failure Details</h2>
|
||||
<ul>
|
||||
<li><strong>Message:</strong> <?php echo htmlspecialchars($context['message'] ?? 'Unknown', ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<li><strong>File:</strong> <?php echo htmlspecialchars($context['file'] ?? 'n/a', ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<li><strong>Line:</strong> <?php echo htmlspecialchars((string)($context['line'] ?? 'n/a'), ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<?php if (!empty($context['plugin'])): ?>
|
||||
<li><strong>Quarantined plugin:</strong> <?php echo htmlspecialchars($context['plugin'], ENT_QUOTES, 'UTF-8'); ?></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<?php if ($quarantine): ?>
|
||||
<div class="card">
|
||||
<h3>Quarantined Plugins</h3>
|
||||
<ul>
|
||||
<?php foreach ($quarantine as $entry): ?>
|
||||
<li>
|
||||
<strong><?php echo htmlspecialchars($entry['slug'], ENT_QUOTES, 'UTF-8'); ?></strong>
|
||||
<small>(disabled at <?php echo date('c', $entry['disabled_at']); ?>)</small><br>
|
||||
<?php echo htmlspecialchars($entry['message'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h3>Rollback</h3>
|
||||
<?php if ($manifests): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="rollback">
|
||||
<label for="manifest">Choose a snapshot</label>
|
||||
<select id="manifest" name="manifest">
|
||||
<?php foreach ($manifests as $manifest): ?>
|
||||
<option value="<?php echo htmlspecialchars($manifest['id'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<?php echo htmlspecialchars($manifest['id'], ENT_QUOTES, 'UTF-8'); ?> — Grav <?php echo htmlspecialchars($manifest['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="submit" class="secondary">Rollback to Selected Snapshot</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p>No upgrade snapshots were found.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="clear-flag">
|
||||
<button type="submit" class="secondary">Exit Recovery Mode</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,6 +46,7 @@ use Grav\Common\Service\SessionServiceProvider;
|
||||
use Grav\Common\Service\StreamsServiceProvider;
|
||||
use Grav\Common\Service\TaskServiceProvider;
|
||||
use Grav\Common\Twig\Twig;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Framework\DI\Container;
|
||||
use Grav\Framework\Psr7\Response;
|
||||
use Grav\Framework\RequestHandler\Middlewares\MultipartRequestSupport;
|
||||
@@ -110,6 +111,7 @@ class Grav extends Container
|
||||
'scheduler' => Scheduler::class,
|
||||
'taxonomy' => Taxonomy::class,
|
||||
'themes' => Themes::class,
|
||||
'recovery' => RecoveryManager::class,
|
||||
'twig' => Twig::class,
|
||||
'uri' => Uri::class,
|
||||
];
|
||||
|
||||
@@ -78,6 +78,9 @@ class InitializeProcessor extends ProcessorBase
|
||||
// Initialize error handlers.
|
||||
$this->initializeErrors();
|
||||
|
||||
// Register recovery shutdown handler early in the lifecycle.
|
||||
$this->container['recovery']->registerHandlers();
|
||||
|
||||
// Initialize debugger.
|
||||
$debugger = $this->initializeDebugger();
|
||||
|
||||
@@ -145,6 +148,9 @@ class InitializeProcessor extends ProcessorBase
|
||||
// Disable debugger.
|
||||
$this->container['debugger']->enabled(false);
|
||||
|
||||
// Register recovery handler for CLI commands as well.
|
||||
$this->container['recovery']->registerHandlers();
|
||||
|
||||
// Set timezone, locale.
|
||||
$this->initializeLocale($config);
|
||||
|
||||
|
||||
289
system/src/Grav/Common/Recovery/RecoveryManager.php
Normal file
289
system/src/Grav/Common/Recovery/RecoveryManager.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Common\Recovery
|
||||
*
|
||||
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
|
||||
* @license MIT License; see LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace Grav\Common\Recovery;
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Yaml;
|
||||
use function bin2hex;
|
||||
use function dirname;
|
||||
use function file_get_contents;
|
||||
use function file_put_contents;
|
||||
use function in_array;
|
||||
use function is_array;
|
||||
use function is_file;
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
use function md5;
|
||||
use function preg_match;
|
||||
use function random_bytes;
|
||||
use function uniqid;
|
||||
use function time;
|
||||
use function trim;
|
||||
use function unlink;
|
||||
use const E_COMPILE_ERROR;
|
||||
use const E_CORE_ERROR;
|
||||
use const E_ERROR;
|
||||
use const E_PARSE;
|
||||
use const GRAV_ROOT;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
use const JSON_UNESCAPED_SLASHES;
|
||||
|
||||
/**
|
||||
* Handles recovery flag lifecycle and plugin quarantine during fatal errors.
|
||||
*/
|
||||
class RecoveryManager
|
||||
{
|
||||
/** @var bool */
|
||||
private $registered = false;
|
||||
/** @var string */
|
||||
private $rootPath;
|
||||
/** @var string */
|
||||
private $userPath;
|
||||
|
||||
public function __construct(?string $rootPath = null)
|
||||
{
|
||||
$root = $rootPath ?? GRAV_ROOT;
|
||||
$this->rootPath = rtrim($root, DIRECTORY_SEPARATOR);
|
||||
$this->userPath = $this->rootPath . '/user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register shutdown handler to capture fatal errors at runtime.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function registerHandlers(): void
|
||||
{
|
||||
if ($this->registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
register_shutdown_function([$this, 'handleShutdown']);
|
||||
$this->registered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recovery mode flag is active.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return is_file($this->flagPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove recovery flag.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$flag = $this->flagPath();
|
||||
if (is_file($flag)) {
|
||||
@unlink($flag);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown handler capturing fatal errors.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handleShutdown(): void
|
||||
{
|
||||
$error = $this->resolveLastError();
|
||||
if (!$error) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = $error['type'] ?? 0;
|
||||
if (!$this->isFatal($type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $error['file'] ?? '';
|
||||
$plugin = $this->detectPluginFromPath($file);
|
||||
$context = [
|
||||
'created_at' => time(),
|
||||
'message' => $error['message'] ?? '',
|
||||
'file' => $file,
|
||||
'line' => $error['line'] ?? null,
|
||||
'type' => $type,
|
||||
'plugin' => $plugin,
|
||||
];
|
||||
|
||||
$this->activate($context);
|
||||
if ($plugin) {
|
||||
$this->quarantinePlugin($plugin, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate recovery mode and record context.
|
||||
*
|
||||
* @param array $context
|
||||
* @return void
|
||||
*/
|
||||
public function activate(array $context): void
|
||||
{
|
||||
$flag = $this->flagPath();
|
||||
if (empty($context['token'])) {
|
||||
$context['token'] = $this->generateToken();
|
||||
}
|
||||
if (!is_file($flag)) {
|
||||
file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
} else {
|
||||
// Merge context if flag already exists.
|
||||
$existing = json_decode(file_get_contents($flag), true);
|
||||
if (is_array($existing)) {
|
||||
$context = $context + $existing;
|
||||
if (empty($context['token'])) {
|
||||
$context['token'] = $this->generateToken();
|
||||
}
|
||||
}
|
||||
file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return last recorded recovery context.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function getContext(): ?array
|
||||
{
|
||||
$flag = $this->flagPath();
|
||||
if (!is_file($flag)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode(file_get_contents($flag), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $slug
|
||||
* @param array $context
|
||||
* @return void
|
||||
*/
|
||||
private function quarantinePlugin(string $slug, array $context): void
|
||||
{
|
||||
$slug = trim($slug);
|
||||
if ($slug === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$configPath = $this->userPath . '/config/plugins/' . $slug . '.yaml';
|
||||
Folder::create(dirname($configPath));
|
||||
|
||||
$configuration = is_file($configPath) ? Yaml::parse(file_get_contents($configPath)) : [];
|
||||
if (!is_array($configuration)) {
|
||||
$configuration = [];
|
||||
}
|
||||
|
||||
if (($configuration['enabled'] ?? true) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$configuration['enabled'] = false;
|
||||
$yaml = Yaml::dump($configuration);
|
||||
file_put_contents($configPath, $yaml);
|
||||
|
||||
$quarantineFile = $this->userPath . '/data/upgrades/quarantine.json';
|
||||
Folder::create(dirname($quarantineFile));
|
||||
|
||||
$quarantine = [];
|
||||
if (is_file($quarantineFile)) {
|
||||
$decoded = json_decode(file_get_contents($quarantineFile), true);
|
||||
if (is_array($decoded)) {
|
||||
$quarantine = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$quarantine[$slug] = [
|
||||
'slug' => $slug,
|
||||
'disabled_at' => time(),
|
||||
'message' => $context['message'] ?? '',
|
||||
'file' => $context['file'] ?? '',
|
||||
'line' => $context['line'] ?? null,
|
||||
];
|
||||
|
||||
file_put_contents($quarantineFile, json_encode($quarantine, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if error type is fatal.
|
||||
*
|
||||
* @param int $type
|
||||
* @return bool
|
||||
*/
|
||||
private function isFatal(int $type): bool
|
||||
{
|
||||
return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to derive plugin slug from file path.
|
||||
*
|
||||
* @param string $file
|
||||
* @return string|null
|
||||
*/
|
||||
private function detectPluginFromPath(string $file): ?string
|
||||
{
|
||||
if (!$file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('#/user/plugins/([^/]+)/#', $file, $matches)) {
|
||||
return $matches[1] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private function flagPath(): string
|
||||
{
|
||||
return $this->rootPath . '/system/recovery.flag';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
protected function generateToken(): string
|
||||
{
|
||||
try {
|
||||
return bin2hex($this->randomBytes(10));
|
||||
} catch (\Throwable $e) {
|
||||
return md5(uniqid('grav-recovery', true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $length
|
||||
* @return string
|
||||
*/
|
||||
protected function randomBytes(int $length): string
|
||||
{
|
||||
return random_bytes($length);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null
|
||||
*/
|
||||
protected function resolveLastError(): ?array
|
||||
{
|
||||
return error_get_last();
|
||||
}
|
||||
}
|
||||
491
system/src/Grav/Common/Upgrade/SafeUpgradeService.php
Normal file
491
system/src/Grav/Common/Upgrade/SafeUpgradeService.php
Normal file
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Common\Upgrade
|
||||
*
|
||||
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
|
||||
* @license MIT License; see LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace Grav\Common\Upgrade;
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\GPM\GPM;
|
||||
use Grav\Common\Yaml;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use function basename;
|
||||
use function count;
|
||||
use function dirname;
|
||||
use function file_get_contents;
|
||||
use function file_put_contents;
|
||||
use function glob;
|
||||
use function in_array;
|
||||
use function is_dir;
|
||||
use function is_file;
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
use function preg_match;
|
||||
use function rename;
|
||||
use function rsort;
|
||||
use function sort;
|
||||
use function time;
|
||||
use function uniqid;
|
||||
use function trim;
|
||||
use function strpos;
|
||||
use function unlink;
|
||||
use const GRAV_ROOT;
|
||||
use const GLOB_ONLYDIR;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
|
||||
/**
|
||||
* Safe upgrade orchestration for Grav core.
|
||||
*/
|
||||
class SafeUpgradeService
|
||||
{
|
||||
/** @var string */
|
||||
private $rootPath;
|
||||
/** @var string */
|
||||
private $parentDir;
|
||||
/** @var string */
|
||||
private $stagingRoot;
|
||||
/** @var string */
|
||||
private $manifestStore;
|
||||
|
||||
/** @var array */
|
||||
private $ignoredDirs = [
|
||||
'backup',
|
||||
'images',
|
||||
'logs',
|
||||
'tmp',
|
||||
'cache',
|
||||
'user',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
*/
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
$root = $options['root'] ?? GRAV_ROOT;
|
||||
$this->rootPath = rtrim($root, DIRECTORY_SEPARATOR);
|
||||
$this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath);
|
||||
$this->stagingRoot = $options['staging_root'] ?? ($this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades');
|
||||
$this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades');
|
||||
if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) {
|
||||
$this->ignoredDirs = $options['ignored_dirs'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run preflight validations before attempting an upgrade.
|
||||
*
|
||||
* @return array{plugins_pending: array<string, array>, psr_log_conflicts: array<string, array>, warnings: string[]}
|
||||
*/
|
||||
public function preflight(): array
|
||||
{
|
||||
$warnings = [];
|
||||
try {
|
||||
$pending = $this->detectPendingPluginUpdates();
|
||||
} catch (RuntimeException $e) {
|
||||
$pending = [];
|
||||
$warnings[] = $e->getMessage();
|
||||
}
|
||||
|
||||
$psrLogConflicts = $this->detectPsrLogConflicts();
|
||||
if ($pending) {
|
||||
$warnings[] = 'One or more plugins/themes are not up to date.';
|
||||
}
|
||||
if ($psrLogConflicts) {
|
||||
$warnings[] = 'Potential psr/log signature conflicts detected.';
|
||||
}
|
||||
|
||||
return [
|
||||
'plugins_pending' => $pending,
|
||||
'psr_log_conflicts' => $psrLogConflicts,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage and promote a Grav update from an extracted folder.
|
||||
*
|
||||
* @param string $extractedPath Path to the extracted update package.
|
||||
* @param string $targetVersion Target Grav version.
|
||||
* @param array<string> $ignores
|
||||
* @return array Manifest data.
|
||||
*/
|
||||
public function promote(string $extractedPath, string $targetVersion, array $ignores): array
|
||||
{
|
||||
if (!is_dir($extractedPath)) {
|
||||
throw new InvalidArgumentException(sprintf('Extracted package path "%s" is not a directory.', $extractedPath));
|
||||
}
|
||||
|
||||
$stageId = uniqid('stage-', false);
|
||||
$stagePath = $this->stagingRoot . DIRECTORY_SEPARATOR . $stageId;
|
||||
$packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package';
|
||||
$backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rollback-' . $stageId;
|
||||
|
||||
Folder::create($packagePath);
|
||||
|
||||
// Copy extracted package into staging area.
|
||||
Folder::rcopy($extractedPath, $packagePath);
|
||||
|
||||
// Ensure ignored directories are replaced with live copies.
|
||||
$this->hydrateIgnoredDirectories($packagePath, $ignores);
|
||||
|
||||
$manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath);
|
||||
$manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json';
|
||||
Folder::create(dirname($manifestPath));
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT));
|
||||
|
||||
// Promote staged package into place.
|
||||
$this->promoteStagedTree($packagePath, $backupPath);
|
||||
$this->persistManifest($manifest);
|
||||
$this->pruneOldSnapshots();
|
||||
Folder::delete($stagePath);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back to the most recent snapshot.
|
||||
*
|
||||
* @param string|null $id
|
||||
* @return array|null
|
||||
*/
|
||||
public function rollback(?string $id = null): ?array
|
||||
{
|
||||
$manifest = $this->resolveManifest($id);
|
||||
if (!$manifest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backupPath = $manifest['backup_path'] ?? null;
|
||||
if (!$backupPath || !is_dir($backupPath)) {
|
||||
throw new RuntimeException('Rollback snapshot is no longer available.');
|
||||
}
|
||||
|
||||
// Put the current tree aside before flip.
|
||||
$rotated = $this->rotateCurrentTree();
|
||||
|
||||
$this->promoteBackup($backupPath);
|
||||
$this->markRollback($manifest['id']);
|
||||
if ($rotated && is_dir($rotated)) {
|
||||
Folder::delete($rotated);
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function clearRecoveryFlag(): void
|
||||
{
|
||||
$flag = $this->rootPath . '/system/recovery.flag';
|
||||
if (is_file($flag)) {
|
||||
@unlink($flag);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array>
|
||||
*/
|
||||
protected function detectPendingPluginUpdates(): array
|
||||
{
|
||||
try {
|
||||
$gpm = new GPM();
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException('Unable to query GPM: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
$updates = $gpm->getUpdatable(['plugins' => true, 'themes' => true]);
|
||||
$pending = [];
|
||||
foreach ($updates as $type => $packages) {
|
||||
if (!is_array($packages)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($packages as $slug => $package) {
|
||||
$pending[$slug] = [
|
||||
'type' => $type,
|
||||
'current' => $package->version ?? null,
|
||||
'available' => $package->available ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check plugins for psr/log requirements that conflict with Grav 1.8 vendor stack.
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
protected function detectPsrLogConflicts(): array
|
||||
{
|
||||
$conflicts = [];
|
||||
$pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($pluginRoots as $path) {
|
||||
$composerFile = $path . '/composer.json';
|
||||
if (!is_file($composerFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$json = json_decode(file_get_contents($composerFile), true);
|
||||
if (!is_array($json)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = basename($path);
|
||||
$rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null);
|
||||
if (!$rawConstraint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$constraint = strtolower((string)$rawConstraint);
|
||||
$compatible = $constraint === '*'
|
||||
|| false !== strpos($constraint, '3')
|
||||
|| false !== strpos($constraint, '4')
|
||||
|| (false !== strpos($constraint, '>=') && preg_match('/>=\s*3/', $constraint));
|
||||
|
||||
if ($compatible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$conflicts[$slug] = [
|
||||
'composer' => $composerFile,
|
||||
'requires' => $rawConstraint,
|
||||
];
|
||||
}
|
||||
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directories flagged for ignoring get hydrated from the current installation.
|
||||
*
|
||||
* @param string $packagePath
|
||||
* @param array<string> $ignores
|
||||
* @return void
|
||||
*/
|
||||
private function hydrateIgnoredDirectories(string $packagePath, array $ignores): void
|
||||
{
|
||||
$strategic = $ignores ?: $this->ignoredDirs;
|
||||
|
||||
foreach ($strategic as $relative) {
|
||||
$relative = trim($relative, '/');
|
||||
if ($relative === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$live = $this->rootPath . '/' . $relative;
|
||||
$stage = $packagePath . '/' . $relative;
|
||||
|
||||
Folder::delete($stage);
|
||||
|
||||
if (!is_dir($live)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip caches to avoid stale data.
|
||||
if (in_array($relative, ['cache', 'tmp'], true)) {
|
||||
Folder::create($stage);
|
||||
continue;
|
||||
}
|
||||
|
||||
Folder::create(dirname($stage));
|
||||
Folder::rcopy($live, $stage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build manifest metadata for a staged upgrade.
|
||||
*
|
||||
* @param string $stageId
|
||||
* @param string $targetVersion
|
||||
* @param string $packagePath
|
||||
* @param string $backupPath
|
||||
* @return array
|
||||
*/
|
||||
private function buildManifest(string $stageId, string $targetVersion, string $packagePath, string $backupPath): array
|
||||
{
|
||||
$plugins = [];
|
||||
$pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($pluginRoots as $path) {
|
||||
$slug = basename($path);
|
||||
$blueprint = $path . '/blueprints.yaml';
|
||||
$details = [
|
||||
'version' => null,
|
||||
'name' => $slug,
|
||||
];
|
||||
|
||||
if (is_file($blueprint)) {
|
||||
try {
|
||||
$yaml = Yaml::parse(file_get_contents($blueprint));
|
||||
if (isset($yaml['version'])) {
|
||||
$details['version'] = $yaml['version'];
|
||||
}
|
||||
if (isset($yaml['name'])) {
|
||||
$details['name'] = $yaml['name'];
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
// ignore parse errors, keep defaults
|
||||
}
|
||||
}
|
||||
|
||||
$plugins[$slug] = $details;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $stageId,
|
||||
'created_at' => time(),
|
||||
'source_version' => GRAV_VERSION,
|
||||
'target_version' => $targetVersion,
|
||||
'php_version' => PHP_VERSION,
|
||||
'package_path' => $packagePath,
|
||||
'backup_path' => $backupPath,
|
||||
'plugins' => $plugins,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote staged package by swapping directory names.
|
||||
*
|
||||
* @param string $packagePath
|
||||
* @param string $backupPath
|
||||
* @return void
|
||||
*/
|
||||
private function promoteStagedTree(string $packagePath, string $backupPath): void
|
||||
{
|
||||
$liveRoot = $this->rootPath;
|
||||
Folder::create(dirname($backupPath));
|
||||
|
||||
if (!rename($liveRoot, $backupPath)) {
|
||||
throw new RuntimeException('Failed to move current Grav directory into backup.');
|
||||
}
|
||||
|
||||
if (!rename($packagePath, $liveRoot)) {
|
||||
// Attempt to restore live tree.
|
||||
rename($backupPath, $liveRoot);
|
||||
throw new RuntimeException('Failed to promote staged Grav release.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move existing tree aside to allow rollback swap.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function rotateCurrentTree(): string
|
||||
{
|
||||
$liveRoot = $this->rootPath;
|
||||
$target = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rotated-' . time();
|
||||
Folder::create($this->stagingRoot);
|
||||
if (!rename($liveRoot, $target)) {
|
||||
throw new RuntimeException('Unable to rotate live tree during rollback.');
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a backup tree into the live position.
|
||||
*
|
||||
* @param string $backupPath
|
||||
* @return void
|
||||
*/
|
||||
private function promoteBackup(string $backupPath): void
|
||||
{
|
||||
if (!rename($backupPath, $this->rootPath)) {
|
||||
throw new RuntimeException('Rollback failed: unable to move backup into live position.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist manifest into Grav data directory.
|
||||
*
|
||||
* @param array $manifest
|
||||
* @return void
|
||||
*/
|
||||
private function persistManifest(array $manifest): void
|
||||
{
|
||||
Folder::create($this->manifestStore);
|
||||
$target = $this->manifestStore . DIRECTORY_SEPARATOR . $manifest['id'] . '.json';
|
||||
file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $id
|
||||
* @return array|null
|
||||
*/
|
||||
private function resolveManifest(?string $id): ?array
|
||||
{
|
||||
$path = null;
|
||||
|
||||
if ($id) {
|
||||
$candidate = $this->manifestStore . DIRECTORY_SEPARATOR . $id . '.json';
|
||||
if (!is_file($candidate)) {
|
||||
return null;
|
||||
}
|
||||
$path = $candidate;
|
||||
} else {
|
||||
$files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||
if (!$files) {
|
||||
return null;
|
||||
}
|
||||
rsort($files);
|
||||
$path = $files[0];
|
||||
}
|
||||
|
||||
$decoded = json_decode(file_get_contents($path), true);
|
||||
|
||||
return $decoded ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record rollback event in manifest store.
|
||||
*
|
||||
* @param string $id
|
||||
* @return void
|
||||
*/
|
||||
private function markRollback(string $id): void
|
||||
{
|
||||
$target = $this->manifestStore . DIRECTORY_SEPARATOR . $id . '.json';
|
||||
if (!is_file($target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manifest = json_decode(file_get_contents($target), true);
|
||||
if (!is_array($manifest)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manifest['rolled_back_at'] = time();
|
||||
file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the three newest snapshots.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function pruneOldSnapshots(): void
|
||||
{
|
||||
$files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||
if (count($files) <= 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
sort($files);
|
||||
$excess = array_slice($files, 0, count($files) - 3);
|
||||
foreach ($excess as $file) {
|
||||
$data = json_decode(file_get_contents($file), true);
|
||||
if (isset($data['backup_path']) && is_dir($data['backup_path'])) {
|
||||
Folder::delete($data['backup_path']);
|
||||
}
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use Grav\Console\Gpm\DirectInstallCommand;
|
||||
use Grav\Console\Gpm\IndexCommand;
|
||||
use Grav\Console\Gpm\InfoCommand;
|
||||
use Grav\Console\Gpm\InstallCommand;
|
||||
use Grav\Console\Gpm\PreflightCommand;
|
||||
use Grav\Console\Gpm\RollbackCommand;
|
||||
use Grav\Console\Gpm\SelfupgradeCommand;
|
||||
use Grav\Console\Gpm\UninstallCommand;
|
||||
use Grav\Console\Gpm\UpdateCommand;
|
||||
@@ -36,6 +38,8 @@ class GpmApplication extends Application
|
||||
new UninstallCommand(),
|
||||
new UpdateCommand(),
|
||||
new SelfupgradeCommand(),
|
||||
new PreflightCommand(),
|
||||
new RollbackCommand(),
|
||||
new DirectInstallCommand(),
|
||||
]);
|
||||
}
|
||||
|
||||
79
system/src/Grav/Console/Gpm/PreflightCommand.php
Normal file
79
system/src/Grav/Console/Gpm/PreflightCommand.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Grav\Console\Gpm;
|
||||
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Console\GpmCommand;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use function count;
|
||||
use function json_encode;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
|
||||
class PreflightCommand extends GpmCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('preflight')
|
||||
->addOption('json', null, InputOption::VALUE_NONE, 'Output report as JSON')
|
||||
->setDescription('Run Grav upgrade preflight checks without modifying the installation.');
|
||||
}
|
||||
|
||||
protected function serve(): int
|
||||
{
|
||||
$io = $this->getIO();
|
||||
$service = $this->createSafeUpgradeService();
|
||||
$report = $service->preflight();
|
||||
|
||||
$hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['warnings']);
|
||||
|
||||
if ($this->getInput()->getOption('json')) {
|
||||
$io->writeln(json_encode($report, JSON_PRETTY_PRINT));
|
||||
|
||||
return $hasIssues ? 2 : 0;
|
||||
}
|
||||
|
||||
$io->title('Grav Upgrade Preflight');
|
||||
|
||||
if (!empty($report['warnings'])) {
|
||||
$io->writeln('<comment>Warnings</comment>');
|
||||
foreach ($report['warnings'] as $warning) {
|
||||
$io->writeln(' - ' . $warning);
|
||||
}
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if (!empty($report['plugins_pending'])) {
|
||||
$io->writeln('<comment>Packages pending update</comment>');
|
||||
foreach ($report['plugins_pending'] as $slug => $info) {
|
||||
$io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $info['type'] ?? 'plugin', $info['current'] ?? 'unknown', $info['available'] ?? 'unknown'));
|
||||
}
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if (!empty($report['psr_log_conflicts'])) {
|
||||
$io->writeln('<comment>Potential psr/log conflicts</comment>');
|
||||
foreach ($report['psr_log_conflicts'] as $slug => $info) {
|
||||
$io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $info['requires'] ?? '*'));
|
||||
}
|
||||
$io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.');
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if (!$hasIssues) {
|
||||
$io->success('No blocking issues detected.');
|
||||
} else {
|
||||
$io->warning('Resolve the findings above before upgrading Grav.');
|
||||
}
|
||||
|
||||
return $hasIssues ? 2 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
return new SafeUpgradeService();
|
||||
}
|
||||
}
|
||||
135
system/src/Grav/Console/Gpm/RollbackCommand.php
Normal file
135
system/src/Grav/Console/Gpm/RollbackCommand.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Grav\Console\Gpm;
|
||||
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Console\GpmCommand;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use function basename;
|
||||
use function file_get_contents;
|
||||
use function glob;
|
||||
use function is_array;
|
||||
use function json_decode;
|
||||
use function pathinfo;
|
||||
use const PATHINFO_FILENAME;
|
||||
use const GRAV_ROOT;
|
||||
|
||||
class RollbackCommand extends GpmCommand
|
||||
{
|
||||
/** @var bool */
|
||||
private $allYes = false;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('rollback')
|
||||
->addArgument('manifest', InputArgument::OPTIONAL, 'Manifest identifier to roll back to. Defaults to the latest snapshot.')
|
||||
->addOption('list', 'l', InputOption::VALUE_NONE, 'List available snapshots')
|
||||
->addOption('all-yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompts')
|
||||
->setDescription('Rollback Grav to a previously staged snapshot.');
|
||||
}
|
||||
|
||||
protected function serve(): int
|
||||
{
|
||||
$input = $this->getInput();
|
||||
$io = $this->getIO();
|
||||
$this->allYes = (bool)$input->getOption('all-yes');
|
||||
|
||||
$snapshots = $this->collectSnapshots();
|
||||
if ($input->getOption('list')) {
|
||||
if (!$snapshots) {
|
||||
$io->writeln('No snapshots found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$io->writeln('<info>Available snapshots:</info>');
|
||||
foreach ($snapshots as $snapshot) {
|
||||
$io->writeln(sprintf(' - %s (Grav %s)', $snapshot['id'], $snapshot['target_version'] ?? 'unknown'));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!$snapshots) {
|
||||
$io->error('No snapshots available to roll back to.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$targetId = $input->getArgument('manifest') ?: $snapshots[0]['id'];
|
||||
$target = null;
|
||||
foreach ($snapshots as $snapshot) {
|
||||
if ($snapshot['id'] === $targetId) {
|
||||
$target = $snapshot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$target) {
|
||||
$io->error(sprintf('Snapshot %s not found.', $targetId));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$this->allYes) {
|
||||
$question = new ConfirmationQuestion(sprintf('Rollback to snapshot %s (Grav %s)? [y|N] ', $target['id'], $target['target_version'] ?? 'unknown'), false);
|
||||
if (!$io->askQuestion($question)) {
|
||||
$io->writeln('Rollback aborted.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->createSafeUpgradeService();
|
||||
|
||||
try {
|
||||
$service->rollback($target['id']);
|
||||
$service->clearRecoveryFlag();
|
||||
} catch (RuntimeException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Rolled back to snapshot %s.', $target['id']));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array>
|
||||
*/
|
||||
protected function collectSnapshots(): array
|
||||
{
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$files = glob($manifestDir . '/*.json');
|
||||
if (!$files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
rsort($files);
|
||||
$snapshots = [];
|
||||
foreach ($files as $file) {
|
||||
$decoded = json_decode(file_get_contents($file), true);
|
||||
if (!is_array($decoded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded['id'] = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME);
|
||||
$decoded['file'] = basename($file);
|
||||
$snapshots[] = $decoded;
|
||||
}
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
return new SafeUpgradeService();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use Grav\Common\HTTP\Response;
|
||||
use Grav\Common\GPM\Installer;
|
||||
use Grav\Common\GPM\Upgrader;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Console\GpmCommand;
|
||||
use Grav\Installer\Install;
|
||||
use RuntimeException;
|
||||
@@ -22,6 +23,7 @@ use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use ZipArchive;
|
||||
use function count;
|
||||
use function is_callable;
|
||||
use function strlen;
|
||||
|
||||
@@ -108,6 +110,12 @@ class SelfupgradeCommand extends GpmCommand
|
||||
|
||||
$this->displayGPMRelease();
|
||||
|
||||
$safeUpgrade = $this->createSafeUpgradeService();
|
||||
$preflight = $safeUpgrade->preflight();
|
||||
if (!$this->handlePreflightReport($preflight)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$update = $this->upgrader->getAssets()['grav-update'];
|
||||
|
||||
$local = $this->upgrader->getLocalVersion();
|
||||
@@ -227,6 +235,7 @@ class SelfupgradeCommand extends GpmCommand
|
||||
} else {
|
||||
$io->writeln(" '- <green>Success!</green> ");
|
||||
$io->newLine();
|
||||
$safeUpgrade->clearRecoveryFlag();
|
||||
}
|
||||
|
||||
if ($this->tmp && is_dir($this->tmp)) {
|
||||
@@ -263,6 +272,79 @@ class SelfupgradeCommand extends GpmCommand
|
||||
return $this->tmp . DS . $package['name'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
return new SafeUpgradeService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $preflight
|
||||
* @return bool
|
||||
*/
|
||||
protected function handlePreflightReport(array $preflight): bool
|
||||
{
|
||||
$io = $this->getIO();
|
||||
$pending = $preflight['plugins_pending'] ?? [];
|
||||
$conflicts = $preflight['psr_log_conflicts'] ?? [];
|
||||
$warnings = $preflight['warnings'] ?? [];
|
||||
|
||||
if (empty($pending) && empty($conflicts)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($warnings) {
|
||||
$io->newLine();
|
||||
$io->writeln('<magenta>Preflight warnings detected:</magenta>');
|
||||
foreach ($warnings as $warning) {
|
||||
$io->writeln(' • ' . $warning);
|
||||
}
|
||||
}
|
||||
|
||||
if ($pending) {
|
||||
$io->newLine();
|
||||
$io->writeln('<yellow>The following packages need updating before Grav upgrade:</yellow>');
|
||||
foreach ($pending as $slug => $info) {
|
||||
$type = $info['type'] ?? 'plugin';
|
||||
$current = $info['current'] ?? 'unknown';
|
||||
$available = $info['available'] ?? 'unknown';
|
||||
$io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $type, $current, $available));
|
||||
}
|
||||
|
||||
if (!$this->all_yes) {
|
||||
$question = new ConfirmationQuestion('Continue without updating these packages? [y|N] ', false);
|
||||
if (!$io->askQuestion($question)) {
|
||||
$io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($conflicts) {
|
||||
$io->newLine();
|
||||
$io->writeln('<yellow>Potential psr/log incompatibilities:</yellow>');
|
||||
foreach ($conflicts as $slug => $info) {
|
||||
$requires = $info['requires'] ?? '*';
|
||||
$io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $requires));
|
||||
}
|
||||
$io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.');
|
||||
|
||||
if (!$this->all_yes) {
|
||||
$question = new ConfirmationQuestion('Continue despite psr/log warnings? [y|N] ', false);
|
||||
if (!$io->askQuestion($question)) {
|
||||
$io->writeln('Aborting self-upgrade. Adjust composer requirements or update affected plugins.');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ use Grav\Common\Cache;
|
||||
use Grav\Common\GPM\Installer;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Plugins;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use RuntimeException;
|
||||
use function class_exists;
|
||||
use function dirname;
|
||||
@@ -260,13 +261,19 @@ ERR;
|
||||
// Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema.
|
||||
$this->updater->install();
|
||||
|
||||
Installer::install(
|
||||
$this->zip ?? '',
|
||||
GRAV_ROOT,
|
||||
['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores],
|
||||
$this->location,
|
||||
!($this->zip && is_file($this->zip))
|
||||
);
|
||||
if ($this->shouldUseSafeUpgrade()) {
|
||||
$service = new SafeUpgradeService();
|
||||
$service->promote($this->location, $this->getVersion(), $this->ignores);
|
||||
Installer::setError(Installer::OK);
|
||||
} else {
|
||||
Installer::install(
|
||||
$this->zip ?? '',
|
||||
GRAV_ROOT,
|
||||
['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores],
|
||||
$this->location,
|
||||
!($this->zip && is_file($this->zip))
|
||||
);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Installer::setError($e->getMessage());
|
||||
}
|
||||
@@ -280,6 +287,27 @@ ERR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldUseSafeUpgrade(): bool
|
||||
{
|
||||
if (!class_exists(SafeUpgradeService::class)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['config'])) {
|
||||
return (bool) $grav['config']->get('system.updates.safe_upgrade', true);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Grav container may not be initialised yet, default to safe upgrade.
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @throws RuntimeException
|
||||
|
||||
123
tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php
Normal file
123
tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
|
||||
class RecoveryManagerTest extends \Codeception\TestCase\Test
|
||||
{
|
||||
/** @var string */
|
||||
private $tmpDir;
|
||||
|
||||
protected function _before(): void
|
||||
{
|
||||
$this->tmpDir = sys_get_temp_dir() . '/grav-recovery-' . uniqid('', true);
|
||||
Folder::create($this->tmpDir);
|
||||
Folder::create($this->tmpDir . '/user');
|
||||
Folder::create($this->tmpDir . '/system');
|
||||
}
|
||||
|
||||
protected function _after(): void
|
||||
{
|
||||
if (is_dir($this->tmpDir)) {
|
||||
Folder::delete($this->tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
public function testHandleShutdownQuarantinesPluginAndCreatesFlag(): void
|
||||
{
|
||||
$plugin = $this->tmpDir . '/user/plugins/bad';
|
||||
Folder::create($plugin);
|
||||
file_put_contents($plugin . '/plugin.php', '<?php // plugin');
|
||||
|
||||
$manager = new class($this->tmpDir) extends RecoveryManager {
|
||||
protected $error;
|
||||
public function __construct(string $rootPath)
|
||||
{
|
||||
parent::__construct($rootPath);
|
||||
$this->error = [
|
||||
'type' => E_ERROR,
|
||||
'file' => $this->getRootPath() . '/user/plugins/bad/plugin.php',
|
||||
'message' => 'Fatal failure',
|
||||
'line' => 42,
|
||||
];
|
||||
}
|
||||
|
||||
public function getRootPath(): string
|
||||
{
|
||||
$prop = new \ReflectionProperty(RecoveryManager::class, 'rootPath');
|
||||
$prop->setAccessible(true);
|
||||
|
||||
return $prop->getValue($this);
|
||||
}
|
||||
|
||||
protected function resolveLastError(): ?array
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
};
|
||||
|
||||
$manager->handleShutdown();
|
||||
|
||||
$flag = $this->tmpDir . '/system/recovery.flag';
|
||||
self::assertFileExists($flag);
|
||||
$context = json_decode(file_get_contents($flag), true);
|
||||
self::assertSame('Fatal failure', $context['message']);
|
||||
self::assertSame('bad', $context['plugin']);
|
||||
self::assertNotEmpty($context['token']);
|
||||
|
||||
$configFile = $this->tmpDir . '/user/config/plugins/bad.yaml';
|
||||
self::assertFileExists($configFile);
|
||||
self::assertStringContainsString('enabled: false', file_get_contents($configFile));
|
||||
|
||||
$quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json';
|
||||
self::assertFileExists($quarantine);
|
||||
$decoded = json_decode(file_get_contents($quarantine), true);
|
||||
self::assertArrayHasKey('bad', $decoded);
|
||||
}
|
||||
|
||||
public function testHandleShutdownIgnoresNonFatalErrors(): void
|
||||
{
|
||||
$manager = new class($this->tmpDir) extends RecoveryManager {
|
||||
protected function resolveLastError(): ?array
|
||||
{
|
||||
return ['type' => E_USER_WARNING, 'message' => 'Notice'];
|
||||
}
|
||||
};
|
||||
|
||||
$manager->handleShutdown();
|
||||
|
||||
self::assertFileDoesNotExist($this->tmpDir . '/system/recovery.flag');
|
||||
}
|
||||
|
||||
public function testClearRemovesFlag(): void
|
||||
{
|
||||
$flag = $this->tmpDir . '/system/recovery.flag';
|
||||
file_put_contents($flag, 'flag');
|
||||
|
||||
$manager = new RecoveryManager($this->tmpDir);
|
||||
$manager->clear();
|
||||
|
||||
self::assertFileDoesNotExist($flag);
|
||||
}
|
||||
|
||||
public function testGenerateTokenFallbackOnRandomFailure(): void
|
||||
{
|
||||
$manager = new class($this->tmpDir) extends RecoveryManager {
|
||||
protected function randomBytes(int $length): string
|
||||
{
|
||||
throw new \RuntimeException('No randomness');
|
||||
}
|
||||
};
|
||||
|
||||
$manager->activate([]);
|
||||
$context = $manager->getContext();
|
||||
|
||||
self::assertNotEmpty($context['token']);
|
||||
}
|
||||
|
||||
public function testGetContextWithoutFlag(): void
|
||||
{
|
||||
$manager = new RecoveryManager($this->tmpDir);
|
||||
self::assertNull($manager->getContext());
|
||||
}
|
||||
}
|
||||
193
tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php
Normal file
193
tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
|
||||
class SafeUpgradeServiceTest extends \Codeception\TestCase\Test
|
||||
{
|
||||
/** @var string */
|
||||
private $tmpDir;
|
||||
|
||||
protected function _before(): void
|
||||
{
|
||||
$this->tmpDir = sys_get_temp_dir() . '/grav-safe-upgrade-' . uniqid('', true);
|
||||
Folder::create($this->tmpDir);
|
||||
}
|
||||
|
||||
protected function _after(): void
|
||||
{
|
||||
if (is_dir($this->tmpDir)) {
|
||||
Folder::delete($this->tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
public function testPreflightAggregatesWarnings(): void
|
||||
{
|
||||
$service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService {
|
||||
public $pending = [
|
||||
'alpha' => ['type' => 'plugins', 'current' => '1.0.0', 'available' => '1.1.0']
|
||||
];
|
||||
public $conflicts = [
|
||||
'beta' => ['requires' => '^1.0']
|
||||
];
|
||||
|
||||
protected function detectPendingPluginUpdates(): array
|
||||
{
|
||||
return $this->pending;
|
||||
}
|
||||
|
||||
protected function detectPsrLogConflicts(): array
|
||||
{
|
||||
return $this->conflicts;
|
||||
}
|
||||
};
|
||||
|
||||
$result = $service->preflight();
|
||||
|
||||
self::assertArrayHasKey('warnings', $result);
|
||||
self::assertCount(2, $result['warnings']);
|
||||
self::assertArrayHasKey('alpha', $result['plugins_pending']);
|
||||
self::assertArrayHasKey('beta', $result['psr_log_conflicts']);
|
||||
}
|
||||
|
||||
public function testPreflightHandlesDetectionFailure(): void
|
||||
{
|
||||
$service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService {
|
||||
protected function detectPendingPluginUpdates(): array
|
||||
{
|
||||
throw new RuntimeException('Cannot reach GPM');
|
||||
}
|
||||
|
||||
protected function detectPsrLogConflicts(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
$result = $service->preflight();
|
||||
|
||||
self::assertSame([], $result['plugins_pending']);
|
||||
self::assertSame([], $result['psr_log_conflicts']);
|
||||
self::assertCount(1, $result['warnings']);
|
||||
self::assertStringContainsString('Cannot reach GPM', $result['warnings'][0]);
|
||||
}
|
||||
|
||||
public function testPromoteAndRollback(): void
|
||||
{
|
||||
[$root, $staging, $manifestStore] = $this->prepareLiveEnvironment();
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'staging_root' => $staging,
|
||||
'manifest_store' => $manifestStore,
|
||||
]);
|
||||
|
||||
$package = $this->preparePackage();
|
||||
$manifest = $service->promote($package, '1.8.0', ['backup', 'cache', 'images', 'logs', 'tmp', 'user']);
|
||||
|
||||
self::assertFileExists($root . '/system/new.txt');
|
||||
self::assertFileDoesNotExist($root . '/ORIGINAL');
|
||||
|
||||
$manifestFile = $manifestStore . '/' . $manifest['id'] . '.json';
|
||||
self::assertFileExists($manifestFile);
|
||||
|
||||
$service->rollback($manifest['id']);
|
||||
|
||||
self::assertFileExists($root . '/ORIGINAL');
|
||||
self::assertFileDoesNotExist($root . '/system/new.txt');
|
||||
|
||||
$rotated = glob($staging . '/rotated-*');
|
||||
self::assertEmpty($rotated);
|
||||
}
|
||||
|
||||
public function testPrunesOldSnapshots(): void
|
||||
{
|
||||
[$root, $staging, $manifestStore] = $this->prepareLiveEnvironment();
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'staging_root' => $staging,
|
||||
'manifest_store' => $manifestStore,
|
||||
]);
|
||||
|
||||
$manifests = [];
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$package = $this->preparePackage((string)$i);
|
||||
$manifests[] = $service->promote($package, '1.8.' . $i, ['backup', 'cache', 'images', 'logs', 'tmp', 'user']);
|
||||
// Ensure subsequent promotions have a marker to restore.
|
||||
file_put_contents($root . '/ORIGINAL', 'state-' . $i);
|
||||
}
|
||||
|
||||
$files = glob($manifestStore . '/*.json');
|
||||
self::assertCount(3, $files);
|
||||
self::assertFalse(is_dir($manifests[0]['backup_path']));
|
||||
}
|
||||
|
||||
public function testDetectsPsrLogConflictsFromFilesystem(): void
|
||||
{
|
||||
[$root] = $this->prepareLiveEnvironment();
|
||||
$plugin = $root . '/user/plugins/problem';
|
||||
Folder::create($plugin);
|
||||
file_put_contents($plugin . '/composer.json', json_encode(['require' => ['psr/log' => '^1.0']], JSON_PRETTY_PRINT));
|
||||
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'staging_root' => $this->tmpDir . '/staging',
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts');
|
||||
$method->setAccessible(true);
|
||||
$conflicts = $method->invoke($service);
|
||||
|
||||
self::assertArrayHasKey('problem', $conflicts);
|
||||
}
|
||||
|
||||
public function testClearRecoveryFlagRemovesFile(): void
|
||||
{
|
||||
[$root] = $this->prepareLiveEnvironment();
|
||||
$flag = $root . '/system/recovery.flag';
|
||||
Folder::create(dirname($flag));
|
||||
file_put_contents($flag, 'flag');
|
||||
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'staging_root' => $this->tmpDir . '/staging',
|
||||
]);
|
||||
$service->clearRecoveryFlag();
|
||||
|
||||
self::assertFileDoesNotExist($flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string,2:string}
|
||||
*/
|
||||
private function prepareLiveEnvironment(): array
|
||||
{
|
||||
$root = $this->tmpDir . '/root';
|
||||
$staging = $this->tmpDir . '/staging';
|
||||
$manifestStore = $root . '/user/data/upgrades';
|
||||
|
||||
Folder::create($root . '/user/plugins/sample');
|
||||
Folder::create($root . '/system');
|
||||
file_put_contents($root . '/system/original.txt', 'original');
|
||||
file_put_contents($root . '/ORIGINAL', 'original-root');
|
||||
file_put_contents($root . '/user/plugins/sample/blueprints.yaml', "name: Sample Plugin\nversion: 1.0.0\n");
|
||||
file_put_contents($root . '/user/plugins/sample/composer.json', json_encode(['require' => ['php' => '^8.0']], JSON_PRETTY_PRINT));
|
||||
|
||||
return [$root, $staging, $manifestStore];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $suffix
|
||||
* @return string
|
||||
*/
|
||||
private function preparePackage(string $suffix = ''): string
|
||||
{
|
||||
$package = $this->tmpDir . '/package-' . uniqid('', true);
|
||||
Folder::create($package . '/system');
|
||||
Folder::create($package . '/user');
|
||||
file_put_contents($package . '/index.php', 'new-release' . $suffix);
|
||||
file_put_contents($package . '/system/new.txt', 'release' . $suffix);
|
||||
|
||||
return $package;
|
||||
}
|
||||
}
|
||||
|
||||
147
tests/unit/Grav/Console/Gpm/PreflightCommandTest.php
Normal file
147
tests/unit/Grav/Console/Gpm/PreflightCommandTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
use Grav\Console\Gpm\PreflightCommand;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class PreflightCommandTest extends \Codeception\TestCase\Test
|
||||
{
|
||||
public function testServeOutputsJsonWhenRequested(): void
|
||||
{
|
||||
$service = new StubSafeUpgradeService([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'warnings' => []
|
||||
]);
|
||||
$command = new TestPreflightCommand($service);
|
||||
|
||||
[$style, $output] = $this->injectIo($command, new ArrayInput(['--json' => true]));
|
||||
$status = $command->runServe();
|
||||
|
||||
self::assertSame(0, $status);
|
||||
$buffer = $output->fetch();
|
||||
self::assertJson(trim($buffer));
|
||||
}
|
||||
|
||||
public function testServeWarnsWhenIssuesDetected(): void
|
||||
{
|
||||
$service = new StubSafeUpgradeService([
|
||||
'plugins_pending' => ['alpha' => ['type' => 'plugin', 'current' => '1', 'available' => '2']],
|
||||
'psr_log_conflicts' => ['beta' => ['requires' => '^1']],
|
||||
'warnings' => ['pending updates']
|
||||
]);
|
||||
$command = new TestPreflightCommand($service);
|
||||
|
||||
[$style] = $this->injectIo($command, new ArrayInput([]));
|
||||
$status = $command->runServe();
|
||||
|
||||
self::assertSame(2, $status);
|
||||
$output = implode("\n", $style->messages);
|
||||
self::assertStringContainsString('pending updates', $output);
|
||||
self::assertStringContainsString('beta', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TestPreflightCommand $command
|
||||
* @param ArrayInput $input
|
||||
* @return array{0:PreflightMemoryStyle,1:BufferedOutput}
|
||||
*/
|
||||
private function injectIo(TestPreflightCommand $command, ArrayInput $input): array
|
||||
{
|
||||
$buffer = new BufferedOutput();
|
||||
$style = new PreflightMemoryStyle($input, $buffer);
|
||||
|
||||
$this->setProtectedProperty($command, 'input', $input);
|
||||
$this->setProtectedProperty($command, 'output', $style);
|
||||
|
||||
$input->bind($command->getDefinition());
|
||||
|
||||
return [$style, $buffer];
|
||||
}
|
||||
|
||||
private function setProtectedProperty(object $object, string $property, $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property);
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($object, $value);
|
||||
}
|
||||
}
|
||||
|
||||
class TestPreflightCommand extends PreflightCommand
|
||||
{
|
||||
/** @var SafeUpgradeService */
|
||||
private $service;
|
||||
|
||||
public function __construct(SafeUpgradeService $service)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
return $this->service;
|
||||
}
|
||||
|
||||
public function runServe(): int
|
||||
{
|
||||
return $this->serve();
|
||||
}
|
||||
}
|
||||
|
||||
class StubSafeUpgradeService extends SafeUpgradeService
|
||||
{
|
||||
/** @var array */
|
||||
private $report;
|
||||
|
||||
public function __construct(array $report)
|
||||
{
|
||||
$this->report = $report;
|
||||
parent::__construct([]);
|
||||
}
|
||||
|
||||
public function preflight(): array
|
||||
{
|
||||
return $this->report;
|
||||
}
|
||||
}
|
||||
|
||||
class PreflightMemoryStyle extends SymfonyStyle
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
public $messages = [];
|
||||
|
||||
public function __construct(InputInterface $input, BufferedOutput $output)
|
||||
{
|
||||
parent::__construct($input, $output);
|
||||
}
|
||||
|
||||
public function title($message): void
|
||||
{
|
||||
$this->messages[] = 'title:' . $message;
|
||||
parent::title($message);
|
||||
}
|
||||
|
||||
public function writeln($messages, $type = self::OUTPUT_NORMAL): void
|
||||
{
|
||||
foreach ((array)$messages as $message) {
|
||||
$this->messages[] = (string)$message;
|
||||
}
|
||||
parent::writeln($messages, $type);
|
||||
}
|
||||
|
||||
public function warning($message): void
|
||||
{
|
||||
$this->messages[] = 'warning:' . $message;
|
||||
parent::warning($message);
|
||||
}
|
||||
|
||||
public function success($message): void
|
||||
{
|
||||
$this->messages[] = 'success:' . $message;
|
||||
parent::success($message);
|
||||
}
|
||||
}
|
||||
244
tests/unit/Grav/Console/Gpm/RollbackCommandTest.php
Normal file
244
tests/unit/Grav/Console/Gpm/RollbackCommandTest.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
use Grav\Console\Gpm\RollbackCommand;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class RollbackCommandTest extends \Codeception\TestCase\Test
|
||||
{
|
||||
public function testListSnapshotsOutputsEntries(): void
|
||||
{
|
||||
$service = new StubRollbackService();
|
||||
$command = new TestRollbackCommand($service);
|
||||
$command->setSnapshots([
|
||||
['id' => 'snap-1', 'target_version' => '1.7.49'],
|
||||
['id' => 'snap-2', 'target_version' => '1.7.50']
|
||||
]);
|
||||
|
||||
[$style] = $this->injectIo($command, new ArrayInput(['--list' => true]));
|
||||
$status = $command->runServe();
|
||||
|
||||
self::assertSame(0, $status);
|
||||
$output = implode("\n", $style->messages);
|
||||
self::assertStringContainsString('snap-1', $output);
|
||||
self::assertStringContainsString('snap-2', $output);
|
||||
self::assertFalse($service->rollbackCalled);
|
||||
}
|
||||
|
||||
public function testListSnapshotsHandlesAbsence(): void
|
||||
{
|
||||
$service = new StubRollbackService();
|
||||
$command = new TestRollbackCommand($service);
|
||||
|
||||
[$style] = $this->injectIo($command, new ArrayInput(['--list' => true]));
|
||||
$status = $command->runServe();
|
||||
|
||||
self::assertSame(0, $status);
|
||||
self::assertStringContainsString('No snapshots found', implode("\n", $style->messages));
|
||||
}
|
||||
|
||||
public function testRollbackAbortsWhenNoSnapshotsAvailable(): void
|
||||
{
|
||||
$service = new StubRollbackService();
|
||||
$command = new TestRollbackCommand($service);
|
||||
|
||||
[$style] = $this->injectIo($command, new ArrayInput([]));
|
||||
$status = $command->runServe();
|
||||
|
||||
self::assertSame(1, $status);
|
||||
self::assertStringContainsString('No snapshots available', implode("\n", $style->messages));
|
||||
}
|
||||
|
||||
public function testRollbackAbortsWhenSnapshotMissing(): void
|
||||
{
|
||||
$service = new StubRollbackService();
|
||||
$command = new TestRollbackCommand($service);
|
||||
$command->setSnapshots([
|
||||
['id' => 'snap-1', 'target_version' => '1.7.49']
|
||||
]);
|
||||
|
||||
[$style] = $this->injectIo($command, new ArrayInput(['manifest' => 'missing']));
|
||||
$status = $command->runServe();
|
||||
|
||||
self::assertSame(1, $status);
|
||||
self::assertStringContainsString('Snapshot missing not found.', implode("\n", $style->messages));
|
||||
}
|
||||
|
||||
public function testRollbackCancelsWhenUserDeclines(): void
|
||||
{
|
||||
$service = new StubRollbackService();
|
||||
$command = new TestRollbackCommand($service, [false]);
|
||||
$command->setSnapshots([
|
||||
['id' => 'snap-1', 'target_version' => '1.7.49']
|
||||
]);
|
||||
|
||||
[$style] = $this->injectIo($command, new ArrayInput([]));
|
||||
$status = $command->runServe();
|
||||
|
||||
self::assertSame(1, $status);
|
||||
self::assertStringContainsString('Rollback aborted.', implode("\n", $style->messages));
|
||||
}
|
||||
|
||||
public function testRollbackSucceedsAndClearsRecoveryFlag(): void
|
||||
{
|
||||
$service = new StubRollbackService();
|
||||
$command = new TestRollbackCommand($service, [true]);
|
||||
$command->setSnapshots([
|
||||
['id' => 'snap-1', 'target_version' => '1.7.49']
|
||||
]);
|
||||
$this->setAllYes($command, true);
|
||||
|
||||
$this->injectIo($command, new ArrayInput([]));
|
||||
$status = $command->runServe();
|
||||
|
||||
self::assertSame(0, $status);
|
||||
self::assertTrue($service->rollbackCalled);
|
||||
self::assertTrue($service->clearFlagCalled);
|
||||
}
|
||||
|
||||
private function setAllYes(RollbackCommand $command, bool $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty(RollbackCommand::class, 'allYes');
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($command, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TestRollbackCommand $command
|
||||
* @param ArrayInput $input
|
||||
* @return array{0:RollbackMemoryStyle}
|
||||
*/
|
||||
private function injectIo(TestRollbackCommand $command, ArrayInput $input): array
|
||||
{
|
||||
$buffer = new BufferedOutput();
|
||||
$style = new RollbackMemoryStyle($input, $buffer, $command->responses);
|
||||
|
||||
$this->setProtectedProperty($command, 'input', $input);
|
||||
$this->setProtectedProperty($command, 'output', $style);
|
||||
|
||||
$input->bind($command->getDefinition());
|
||||
|
||||
return [$style];
|
||||
}
|
||||
|
||||
private function setProtectedProperty(object $object, string $property, $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property);
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($object, $value);
|
||||
}
|
||||
}
|
||||
|
||||
class TestRollbackCommand extends RollbackCommand
|
||||
{
|
||||
/** @var SafeUpgradeService */
|
||||
private $service;
|
||||
/** @var array<int, array> */
|
||||
private $snapshots = [];
|
||||
/** @var array<int, mixed> */
|
||||
public $responses = [];
|
||||
|
||||
public function __construct(SafeUpgradeService $service, array $responses = [])
|
||||
{
|
||||
parent::__construct();
|
||||
$this->service = $service;
|
||||
$this->responses = $responses;
|
||||
}
|
||||
|
||||
public function setSnapshots(array $snapshots): void
|
||||
{
|
||||
$this->snapshots = $snapshots;
|
||||
}
|
||||
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
return $this->service;
|
||||
}
|
||||
|
||||
protected function collectSnapshots(): array
|
||||
{
|
||||
return $this->snapshots;
|
||||
}
|
||||
|
||||
public function runServe(): int
|
||||
{
|
||||
return $this->serve();
|
||||
}
|
||||
}
|
||||
|
||||
class StubRollbackService extends SafeUpgradeService
|
||||
{
|
||||
public $rollbackCalled = false;
|
||||
public $clearFlagCalled = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct([]);
|
||||
}
|
||||
|
||||
public function rollback(?string $id = null): ?array
|
||||
{
|
||||
$this->rollbackCalled = true;
|
||||
|
||||
return ['id' => $id];
|
||||
}
|
||||
|
||||
public function clearRecoveryFlag(): void
|
||||
{
|
||||
$this->clearFlagCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
class RollbackMemoryStyle extends SymfonyStyle
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
public $messages = [];
|
||||
/** @var array<int, mixed> */
|
||||
private $responses;
|
||||
|
||||
public function __construct(InputInterface $input, BufferedOutput $output, array $responses = [])
|
||||
{
|
||||
parent::__construct($input, $output);
|
||||
$this->responses = $responses;
|
||||
}
|
||||
|
||||
public function newLine($count = 1): void
|
||||
{
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->messages[] = '';
|
||||
}
|
||||
parent::newLine($count);
|
||||
}
|
||||
|
||||
public function writeln($messages, $type = self::OUTPUT_NORMAL): void
|
||||
{
|
||||
foreach ((array)$messages as $message) {
|
||||
$this->messages[] = (string)$message;
|
||||
}
|
||||
parent::writeln($messages, $type);
|
||||
}
|
||||
|
||||
public function error($message): void
|
||||
{
|
||||
$this->messages[] = 'error:' . $message;
|
||||
parent::error($message);
|
||||
}
|
||||
|
||||
public function success($message): void
|
||||
{
|
||||
$this->messages[] = 'success:' . $message;
|
||||
parent::success($message);
|
||||
}
|
||||
|
||||
public function askQuestion($question)
|
||||
{
|
||||
if ($this->responses) {
|
||||
return array_shift($this->responses);
|
||||
}
|
||||
|
||||
return parent::askQuestion($question);
|
||||
}
|
||||
}
|
||||
160
tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php
Normal file
160
tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
use Grav\Console\Gpm\SelfupgradeCommand;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class SelfupgradeCommandTest extends \Codeception\TestCase\Test
|
||||
{
|
||||
public function testHandlePreflightReportSucceedsWithoutIssues(): void
|
||||
{
|
||||
$command = new TestSelfupgradeCommand();
|
||||
[$style] = $this->injectIo($command);
|
||||
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'warnings' => []
|
||||
]);
|
||||
|
||||
self::assertTrue($result);
|
||||
self::assertSame([], $style->messages);
|
||||
}
|
||||
|
||||
public function testHandlePreflightReportSkipsPromptsWhenAllYes(): void
|
||||
{
|
||||
$command = new TestSelfupgradeCommand();
|
||||
[$style] = $this->injectIo($command);
|
||||
$this->setAllYes($command, true);
|
||||
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']],
|
||||
'psr_log_conflicts' => ['bar' => ['requires' => '^1.0']],
|
||||
'warnings' => ['pending']
|
||||
]);
|
||||
|
||||
self::assertTrue($result);
|
||||
$output = implode("\n", $style->messages);
|
||||
self::assertStringContainsString('pending', $output);
|
||||
self::assertStringContainsString('psr/log', $output);
|
||||
}
|
||||
|
||||
public function testHandlePreflightReportAbortsOnPendingWhenDeclined(): void
|
||||
{
|
||||
$command = new TestSelfupgradeCommand();
|
||||
[$style] = $this->injectIo($command, [false]);
|
||||
$this->setAllYes($command, false);
|
||||
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']],
|
||||
'psr_log_conflicts' => [],
|
||||
'warnings' => []
|
||||
]);
|
||||
|
||||
self::assertFalse($result);
|
||||
self::assertStringContainsString('Run `bin/gpm update` first', implode("\n", $style->messages));
|
||||
}
|
||||
|
||||
public function testHandlePreflightReportAbortsOnConflictWhenDeclined(): void
|
||||
{
|
||||
$command = new TestSelfupgradeCommand();
|
||||
[$style] = $this->injectIo($command, [false]);
|
||||
$this->setAllYes($command, false);
|
||||
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']],
|
||||
'warnings' => []
|
||||
]);
|
||||
|
||||
self::assertFalse($result);
|
||||
self::assertStringContainsString('Adjust composer requirements', implode("\n", $style->messages));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TestSelfupgradeCommand $command
|
||||
* @param array<int, mixed> $responses
|
||||
* @return array{0:SelfUpgradeMemoryStyle,1:InputInterface}
|
||||
*/
|
||||
private function injectIo(TestSelfupgradeCommand $command, array $responses = []): array
|
||||
{
|
||||
$input = new ArrayInput([]);
|
||||
$buffer = new BufferedOutput();
|
||||
$style = new SelfUpgradeMemoryStyle($input, $buffer, $responses);
|
||||
|
||||
$this->setProtectedProperty($command, 'input', $input);
|
||||
$this->setProtectedProperty($command, 'output', $style);
|
||||
|
||||
$input->bind($command->getDefinition());
|
||||
|
||||
return [$style, $input];
|
||||
}
|
||||
|
||||
private function setAllYes(SelfupgradeCommand $command, bool $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty(SelfupgradeCommand::class, 'all_yes');
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($command, $value);
|
||||
}
|
||||
|
||||
private function setProtectedProperty(object $object, string $property, $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property);
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($object, $value);
|
||||
}
|
||||
}
|
||||
|
||||
class TestSelfupgradeCommand extends SelfupgradeCommand
|
||||
{
|
||||
public function runHandle(array $report): bool
|
||||
{
|
||||
return $this->handlePreflightReport($report);
|
||||
}
|
||||
}
|
||||
|
||||
class SelfUpgradeMemoryStyle extends SymfonyStyle
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
public $messages = [];
|
||||
/** @var array<int, mixed> */
|
||||
private $responses;
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param BufferedOutput $output
|
||||
* @param array<int, mixed> $responses
|
||||
*/
|
||||
public function __construct(InputInterface $input, BufferedOutput $output, array $responses = [])
|
||||
{
|
||||
parent::__construct($input, $output);
|
||||
$this->responses = $responses;
|
||||
}
|
||||
|
||||
public function newLine($count = 1): void
|
||||
{
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->messages[] = '';
|
||||
}
|
||||
parent::newLine($count);
|
||||
}
|
||||
|
||||
public function writeln($messages, $type = self::OUTPUT_NORMAL): void
|
||||
{
|
||||
foreach ((array)$messages as $message) {
|
||||
$this->messages[] = (string)$message;
|
||||
}
|
||||
parent::writeln($messages, $type);
|
||||
}
|
||||
|
||||
public function askQuestion($question)
|
||||
{
|
||||
if ($this->responses) {
|
||||
return array_shift($this->responses);
|
||||
}
|
||||
|
||||
return parent::askQuestion($question);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user