mirror of
https://github.com/getgrav/grav.git
synced 2026-05-09 00:26:33 +02:00
Remove SafeUpgradeService, add family-aware GPM upgrade gate
- Remove SafeUpgradeService, RollbackCommand, SafeUpgradeRunCommand, and bin/restore - Simplify Install.php: remove all snapshot logic; keep forceSafeUpgrade() as no-op stub for backward compat with pre-2.0 upgrade scripts that call it in finally blocks - Simplify SelfupgradeCommand: remove --safe/--legacy flags, safe-upgrade window tracking, and manifest block; keep RecoveryManager plugin-disable calls - Simplify PreflightCommand: use Install::instance()->generatePreflightReport() directly - Simplify recovery.php: remove auth, rollback, and quarantine sections; keep clear-flag and disable-recovery actions - Upgrader::isUpgradable() now returns false when remote is a different major.minor family (e.g. 1.8.x will not auto-upgrade to 2.0.x) - Add Upgrader::isNextMajorAvailable() for future informational notices - Add bin/gpm-cache-inject helper for local E2E testing without a live server - Add UpgraderFamilyTest covering all cross-family and same-family scenarios (10/10)
This commit is contained in:
160
bin/gpm-cache-inject
Executable file
160
bin/gpm-cache-inject
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GPM cache injector for testing family-aware upgrade behavior.
|
||||
*
|
||||
* Writes a fake GPM JSON response into the Grav file cache so you can test
|
||||
* what happens when the server reports a particular version (e.g. 2.0.0)
|
||||
* without actually deploying server-side changes.
|
||||
*
|
||||
* Usage:
|
||||
* php bin/gpm-cache-inject [fake-version] [channel]
|
||||
*
|
||||
* Examples:
|
||||
* php bin/gpm-cache-inject 2.0.0 # fake server returning 2.0.0 (stable channel)
|
||||
* php bin/gpm-cache-inject 1.8.0-beta.29 # fake a specific 1.8 beta
|
||||
* php bin/gpm-cache-inject 2.0.0 testing # fake 2.0.0 on the testing channel
|
||||
* php bin/gpm-cache-inject --clear # delete cached GPM data (forces re-fetch)
|
||||
*
|
||||
* After running this, execute:
|
||||
* bin/gpm selfupgrade
|
||||
*
|
||||
* Then restore real data with:
|
||||
* php bin/gpm-cache-inject --clear
|
||||
*/
|
||||
|
||||
if (!defined('GRAV_ROOT')) {
|
||||
define('GRAV_ROOT', dirname(__DIR__));
|
||||
}
|
||||
define('DS', DIRECTORY_SEPARATOR);
|
||||
|
||||
// We only need the autoloader — no full Grav bootstrap
|
||||
$autoload = GRAV_ROOT . '/vendor/autoload.php';
|
||||
if (!file_exists($autoload)) {
|
||||
fwrite(STDERR, "ERROR: vendor/autoload.php not found. Run: composer install\n");
|
||||
exit(1);
|
||||
}
|
||||
require $autoload;
|
||||
|
||||
use Doctrine\Common\Cache\FilesystemCache;
|
||||
|
||||
// ── Parse arguments ───────────────────────────────────────────────────────────
|
||||
|
||||
$fakeVersion = null;
|
||||
$channel = 'stable';
|
||||
$clear = false;
|
||||
|
||||
foreach (array_slice($argv, 1) as $arg) {
|
||||
if ($arg === '--clear') {
|
||||
$clear = true;
|
||||
} elseif ($arg === 'stable' || $arg === 'testing') {
|
||||
$channel = $arg;
|
||||
} else {
|
||||
$fakeVersion = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve GRAV_VERSION from this install ────────────────────────────────────
|
||||
|
||||
// Read from the system/src/defines.php or CHANGELOG rather than bootstrapping Grav
|
||||
$changelogFile = GRAV_ROOT . '/CHANGELOG.md';
|
||||
$gravVersion = 'unknown';
|
||||
if (file_exists($changelogFile)) {
|
||||
$first = fopen($changelogFile, 'r');
|
||||
while (($line = fgets($first)) !== false) {
|
||||
if (preg_match('/^#\s+([\d]+\.[\d]+\.[\d]+(?:[-\w.]+)?)/', $line, $m)) {
|
||||
$gravVersion = $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose($first);
|
||||
}
|
||||
|
||||
if ($gravVersion === 'unknown') {
|
||||
fwrite(STDERR, "ERROR: Could not determine GRAV_VERSION from CHANGELOG.md\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Build the exact repository URL GravCore uses ─────────────────────────────
|
||||
|
||||
$repositoryUrl = 'https://getgrav.org/downloads/grav.json?v=' . $gravVersion . '&' . $channel . '=1';
|
||||
$cacheKey = md5($repositoryUrl);
|
||||
$cacheDir = GRAV_ROOT . '/cache/gpm';
|
||||
|
||||
echo "GRAV_VERSION : $gravVersion\n";
|
||||
echo "Channel : $channel\n";
|
||||
echo "URL (key) : $repositoryUrl\n";
|
||||
echo "Cache key : $cacheKey\n";
|
||||
echo "Cache dir : $cacheDir\n\n";
|
||||
|
||||
$cache = new FilesystemCache($cacheDir);
|
||||
|
||||
// ── Clear mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
if ($clear) {
|
||||
$cache->delete($cacheKey);
|
||||
echo "Cache cleared. Next GPM call will fetch live data from getgrav.org.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// ── Inject mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (!$fakeVersion) {
|
||||
fwrite(STDERR, "ERROR: Provide a version to inject, e.g.:\n php bin/gpm-cache-inject 2.0.0\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$fakeJson = json_encode([
|
||||
'version' => $fakeVersion,
|
||||
'date' => date('Y-m-d\TH:i:s\Z'),
|
||||
'assets' => [
|
||||
'grav' => [
|
||||
'name' => "grav-v{$fakeVersion}.zip",
|
||||
'type' => 'application/zip',
|
||||
'size' => 1000000,
|
||||
'download' => "https://getgrav.org/download/core/grav/{$fakeVersion}",
|
||||
],
|
||||
'grav-admin' => [
|
||||
'name' => "grav-admin-v{$fakeVersion}.zip",
|
||||
'type' => 'application/zip',
|
||||
'size' => 1500000,
|
||||
'download' => "https://getgrav.org/download/core/grav-admin/{$fakeVersion}",
|
||||
],
|
||||
],
|
||||
'url' => "https://github.com/getgrav/grav/releases/tag/{$fakeVersion}",
|
||||
'min_php' => '8.1.0',
|
||||
'changelog' => new stdClass(),
|
||||
], JSON_PRETTY_PRINT);
|
||||
|
||||
$lifetime = 86400; // match GravCore's lifetime
|
||||
$saved = $cache->save($cacheKey, $fakeJson, $lifetime);
|
||||
|
||||
if ($saved) {
|
||||
echo "Injected fake GPM response:\n";
|
||||
echo " Fake version : $fakeVersion\n";
|
||||
echo " Channel : $channel\n\n";
|
||||
echo "Now run:\n";
|
||||
echo " bin/gpm selfupgrade\n\n";
|
||||
echo "Expected results with the family gate active:\n";
|
||||
$localParts = explode('.', $gravVersion);
|
||||
$remoteParts = explode('.', ltrim($fakeVersion, 'v'));
|
||||
$localFamily = ($localParts[0] ?? '0') . '.' . ($localParts[1] ?? '0');
|
||||
$remoteFamily = ($remoteParts[0] ?? '0') . '.' . ($remoteParts[1] ?? '0');
|
||||
if ($localFamily !== $remoteFamily) {
|
||||
echo " isUpgradable() → FALSE (cross-family: $localFamily → $remoteFamily)\n";
|
||||
$localMajor = (int)($localParts[0] ?? 0);
|
||||
$remoteMajor = (int)($remoteParts[0] ?? 0);
|
||||
$notice = $remoteMajor > $localMajor ? 'TRUE' : 'false';
|
||||
echo " isNextMajorAvailable()→ $notice\n";
|
||||
} else {
|
||||
$upgradable = version_compare($gravVersion, $fakeVersion, '<') ? 'TRUE' : 'false';
|
||||
echo " isUpgradable() → $upgradable (same family: $localFamily)\n";
|
||||
echo " isNextMajorAvailable()→ false\n";
|
||||
}
|
||||
echo "\nClean up with:\n";
|
||||
echo " php bin/gpm-cache-inject --clear\n";
|
||||
} else {
|
||||
fwrite(STDERR, "ERROR: Failed to write cache. Check that $cacheDir is writable.\n");
|
||||
exit(1);
|
||||
}
|
||||
634
bin/restore
634
bin/restore
@@ -1,634 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Grav Snapshot Restore Utility
|
||||
*
|
||||
* Lightweight CLI that can list and apply safe-upgrade snapshots without
|
||||
* bootstrapping the full Grav application (or any plugins).
|
||||
*/
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
|
||||
define('GRAV_CLI', true);
|
||||
define('GRAV_REQUEST_TIME', microtime(true));
|
||||
|
||||
if (!file_exists($root . '/vendor/autoload.php')) {
|
||||
fwrite(STDERR, "Unable to locate vendor/autoload.php. Run composer install first.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$autoload = require $root . '/vendor/autoload.php';
|
||||
|
||||
if (!file_exists($root . '/index.php')) {
|
||||
fwrite(STDERR, "FATAL: Must be run from Grav root directory.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
const RESTORE_USAGE = <<<USAGE
|
||||
Grav Restore Utility
|
||||
|
||||
Usage:
|
||||
bin/restore list [--staging-root=/absolute/path]
|
||||
Lists all available snapshots (most recent first).
|
||||
|
||||
bin/restore apply <snapshot-id> [--staging-root=/absolute/path]
|
||||
Restores the specified snapshot created by safe-upgrade.
|
||||
|
||||
bin/restore remove [<snapshot-id> ...] [--staging-root=/absolute/path]
|
||||
Deletes one or more snapshots (interactive selection when no id provided).
|
||||
|
||||
bin/restore snapshot [--label=\"optional description\"] [--staging-root=/absolute/path]
|
||||
Creates a manual snapshot of the current Grav core files.
|
||||
|
||||
bin/restore recovery [status|clear]
|
||||
Shows the recovery flag context or clears it.
|
||||
|
||||
Options:
|
||||
--staging-root Overrides the staging directory (defaults to configured value).
|
||||
--label Optional label to store with the manual snapshot.
|
||||
|
||||
Examples:
|
||||
bin/restore list
|
||||
bin/restore apply stage-68eff31cc4104
|
||||
bin/restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups
|
||||
bin/restore snapshot --label=\"Before plugin install\"
|
||||
bin/restore recovery status
|
||||
bin/restore recovery clear
|
||||
USAGE;
|
||||
|
||||
/**
|
||||
* @param array $args
|
||||
* @return array{command:string,arguments:array,options:array}
|
||||
*/
|
||||
function parseArguments(array $args): array
|
||||
{
|
||||
array_shift($args); // remove script name
|
||||
|
||||
$command = null;
|
||||
$arguments = [];
|
||||
$options = [];
|
||||
|
||||
while ($args) {
|
||||
$arg = array_shift($args);
|
||||
if (strncmp($arg, '--', 2) === 0) {
|
||||
$parts = explode('=', substr($arg, 2), 2);
|
||||
$name = $parts[0] ?? '';
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$value = $parts[1] ?? null;
|
||||
if ($value === null && $args && substr($args[0], 0, 2) !== '--') {
|
||||
$value = array_shift($args);
|
||||
}
|
||||
$options[$name] = $value ?? true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null === $command) {
|
||||
$command = $arg;
|
||||
} else {
|
||||
$arguments[] = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $command) {
|
||||
$command = 'interactive';
|
||||
}
|
||||
|
||||
return [
|
||||
'command' => $command,
|
||||
'arguments' => $arguments,
|
||||
'options' => $options,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
function createUpgradeService(array $options): SafeUpgradeService
|
||||
{
|
||||
$serviceOptions = ['root' => GRAV_ROOT];
|
||||
|
||||
if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') {
|
||||
$serviceOptions['staging_root'] = $options['staging-root'];
|
||||
}
|
||||
|
||||
return new SafeUpgradeService($serviceOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}>
|
||||
*/
|
||||
function loadSnapshots(): array
|
||||
{
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
if (!is_dir($manifestDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob($manifestDir . '/*.json') ?: [];
|
||||
rsort($files);
|
||||
|
||||
$snapshots = [];
|
||||
foreach ($files as $file) {
|
||||
$decoded = json_decode(file_get_contents($file) ?: '', true);
|
||||
if (!is_array($decoded) || empty($decoded['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshots[] = [
|
||||
'id' => $decoded['id'],
|
||||
'label' => $decoded['label'] ?? null,
|
||||
'source_version' => $decoded['source_version'] ?? null,
|
||||
'target_version' => $decoded['target_version'] ?? null,
|
||||
'created_at' => (int)($decoded['created_at'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
||||
* @return string
|
||||
*/
|
||||
function formatSnapshotListLine(array $snapshot): string
|
||||
{
|
||||
$restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown';
|
||||
$timeLabel = formatSnapshotTimestamp($snapshot['created_at']);
|
||||
$label = $snapshot['label'] ?? null;
|
||||
$display = $label ? sprintf('%s [%s]', $label, $snapshot['id']) : $snapshot['id'];
|
||||
|
||||
return sprintf('%s (restore to Grav %s, %s)', $display, $restoreVersion, $timeLabel);
|
||||
}
|
||||
|
||||
function formatSnapshotTimestamp(int $timestamp): string
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return 'time unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
$timezone = resolveTimezone();
|
||||
$dt = new DateTime('@' . $timestamp);
|
||||
$dt->setTimezone($timezone);
|
||||
$formatted = $dt->format('Y-m-d H:i:s T');
|
||||
} catch (\Throwable $e) {
|
||||
$formatted = date('Y-m-d H:i:s T', $timestamp);
|
||||
}
|
||||
|
||||
return $formatted . ' (' . formatRelative(time() - $timestamp) . ')';
|
||||
}
|
||||
|
||||
function resolveTimezone(): DateTimeZone
|
||||
{
|
||||
static $resolved = null;
|
||||
if ($resolved instanceof DateTimeZone) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$timezone = null;
|
||||
$configFile = GRAV_ROOT . '/user/config/system.yaml';
|
||||
if (is_file($configFile)) {
|
||||
try {
|
||||
$data = Yaml::parse(file_get_contents($configFile) ?: '') ?: [];
|
||||
if (!empty($data['system']['timezone']) && is_string($data['system']['timezone'])) {
|
||||
$timezone = $data['system']['timezone'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore parse errors, fallback below
|
||||
}
|
||||
}
|
||||
|
||||
if (!$timezone) {
|
||||
$timezone = ini_get('date.timezone') ?: 'UTC';
|
||||
}
|
||||
|
||||
try {
|
||||
$resolved = new DateTimeZone($timezone);
|
||||
} catch (\Throwable $e) {
|
||||
$resolved = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
function formatRelative(int $seconds): string
|
||||
{
|
||||
if ($seconds < 5) {
|
||||
return 'just now';
|
||||
}
|
||||
$negative = $seconds < 0;
|
||||
$seconds = abs($seconds);
|
||||
$units = [
|
||||
31536000 => 'y',
|
||||
2592000 => 'mo',
|
||||
604800 => 'w',
|
||||
86400 => 'd',
|
||||
3600 => 'h',
|
||||
60 => 'm',
|
||||
1 => 's',
|
||||
];
|
||||
foreach ($units as $size => $label) {
|
||||
if ($seconds >= $size) {
|
||||
$value = (int)floor($seconds / $size);
|
||||
$suffix = $label === 'mo' ? 'month' : ($label === 'y' ? 'year' : ($label === 'w' ? 'week' : ($label === 'd' ? 'day' : ($label === 'h' ? 'hour' : ($label === 'm' ? 'minute' : 'second')))));
|
||||
if ($value !== 1) {
|
||||
$suffix .= 's';
|
||||
}
|
||||
$phrase = $value . ' ' . $suffix;
|
||||
return $negative ? 'in ' . $phrase : $phrase . ' ago';
|
||||
}
|
||||
}
|
||||
|
||||
return $negative ? 'in 0 seconds' : '0 seconds ago';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $snapshotId
|
||||
* @param array $options
|
||||
* @return void
|
||||
*/
|
||||
function applySnapshot(string $snapshotId, array $options): void
|
||||
{
|
||||
try {
|
||||
$service = createUpgradeService($options);
|
||||
$manifest = $service->rollback($snapshotId);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!$manifest) {
|
||||
fwrite(STDERR, "Snapshot {$snapshotId} not found.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
|
||||
echo "Restored snapshot {$snapshotId} (Grav {$version}).\n";
|
||||
if (!empty($manifest['id'])) {
|
||||
echo "Snapshot manifest: {$manifest['id']}\n";
|
||||
}
|
||||
if (!empty($manifest['backup_path'])) {
|
||||
echo "Snapshot path: {$manifest['backup_path']}\n";
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
* @return void
|
||||
*/
|
||||
function createManualSnapshot(array $options): void
|
||||
{
|
||||
$label = null;
|
||||
if (isset($options['label']) && is_string($options['label'])) {
|
||||
$label = trim($options['label']);
|
||||
if ($label === '') {
|
||||
$label = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$service = createUpgradeService($options);
|
||||
$manifest = $service->createSnapshot($label);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Snapshot creation failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$snapshotId = $manifest['id'] ?? null;
|
||||
if (!$snapshotId) {
|
||||
$snapshotId = 'unknown';
|
||||
}
|
||||
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
|
||||
|
||||
echo "Created snapshot {$snapshotId} (Grav {$version}).\n";
|
||||
if ($label) {
|
||||
echo "Label: {$label}\n";
|
||||
}
|
||||
if (!empty($manifest['backup_path'])) {
|
||||
echo "Snapshot path: {$manifest['backup_path']}\n";
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
||||
* @return string|null
|
||||
*/
|
||||
function promptSnapshotSelection(array $snapshots): ?string
|
||||
{
|
||||
echo "Available snapshots:\n";
|
||||
foreach ($snapshots as $index => $snapshot) {
|
||||
$line = formatSnapshotListLine($snapshot);
|
||||
$number = $index + 1;
|
||||
echo sprintf(" [%d] %s\n", $number, $line);
|
||||
}
|
||||
|
||||
$default = $snapshots[0]['id'];
|
||||
echo "\nSelect a snapshot to restore [1]: ";
|
||||
$input = trim((string)fgets(STDIN));
|
||||
|
||||
if ($input === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (ctype_digit($input)) {
|
||||
$idx = (int)$input - 1;
|
||||
if (isset($snapshots[$idx])) {
|
||||
return $snapshots[$idx]['id'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($snapshots as $snapshot) {
|
||||
if (strcasecmp($snapshot['id'], $input) === 0) {
|
||||
return $snapshot['id'];
|
||||
}
|
||||
}
|
||||
|
||||
echo "Invalid selection. Aborting.\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
||||
* @return array<string>
|
||||
*/
|
||||
function promptSnapshotsRemoval(array $snapshots): array
|
||||
{
|
||||
echo "Available snapshots:\n";
|
||||
foreach ($snapshots as $index => $snapshot) {
|
||||
$line = formatSnapshotListLine($snapshot);
|
||||
$number = $index + 1;
|
||||
echo sprintf(" [%d] %s\n", $number, $line);
|
||||
}
|
||||
|
||||
echo "\nSelect snapshots to remove (comma or space separated numbers / ids, 'all' for everything, empty to cancel): ";
|
||||
$input = trim((string)fgets(STDIN));
|
||||
|
||||
if ($input === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$inputLower = strtolower($input);
|
||||
if ($inputLower === 'all' || $inputLower === '*') {
|
||||
return array_values(array_unique(array_column($snapshots, 'id')));
|
||||
}
|
||||
|
||||
$tokens = preg_split('/[\\s,]+/', $input, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$selected = [];
|
||||
foreach ($tokens as $token) {
|
||||
if (ctype_digit($token)) {
|
||||
$idx = (int)$token - 1;
|
||||
if (isset($snapshots[$idx])) {
|
||||
$selected[] = $snapshots[$idx]['id'];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($snapshots as $snapshot) {
|
||||
if (strcasecmp($snapshot['id'], $token) === 0) {
|
||||
$selected[] = $snapshot['id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($selected)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $snapshotId
|
||||
* @return array{success:bool,message:string}
|
||||
*/
|
||||
function removeSnapshot(string $snapshotId): array
|
||||
{
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$manifestPath = $manifestDir . '/' . $snapshotId . '.json';
|
||||
if (!is_file($manifestPath)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => "Snapshot {$snapshotId} not found."
|
||||
];
|
||||
}
|
||||
|
||||
$manifest = json_decode(file_get_contents($manifestPath) ?: '', true);
|
||||
if (!is_array($manifest)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => "Snapshot {$snapshotId} manifest is invalid."
|
||||
];
|
||||
}
|
||||
|
||||
$pathsToDelete = [];
|
||||
foreach (['package_path', 'backup_path'] as $key) {
|
||||
if (!empty($manifest[$key]) && is_string($manifest[$key])) {
|
||||
$pathsToDelete[] = $manifest[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
foreach ($pathsToDelete as $path) {
|
||||
if (!$path) {
|
||||
continue;
|
||||
}
|
||||
if (!file_exists($path)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (is_dir($path)) {
|
||||
Folder::delete($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = "Unable to remove {$path}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if (!@unlink($manifestPath)) {
|
||||
$errors[] = "Unable to delete manifest file {$manifestPath}.";
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => implode(' ', $errors)
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "Removed snapshot {$snapshotId}."
|
||||
];
|
||||
}
|
||||
|
||||
$cli = parseArguments($argv);
|
||||
$command = $cli['command'];
|
||||
$arguments = $cli['arguments'];
|
||||
$options = $cli['options'];
|
||||
|
||||
switch ($command) {
|
||||
case 'interactive':
|
||||
$snapshots = loadSnapshots();
|
||||
if (!$snapshots) {
|
||||
echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$selection = promptSnapshotSelection($snapshots);
|
||||
if (!$selection) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
applySnapshot($selection, $options);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
$snapshots = loadSnapshots();
|
||||
if (!$snapshots) {
|
||||
echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
echo "Available snapshots:\n";
|
||||
foreach ($snapshots as $snapshot) {
|
||||
echo ' - ' . formatSnapshotListLine($snapshot) . "\n";
|
||||
}
|
||||
exit(0);
|
||||
|
||||
case 'remove':
|
||||
$snapshots = loadSnapshots();
|
||||
if (!$snapshots) {
|
||||
echo "No snapshots found. Nothing to remove.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$selectedIds = [];
|
||||
if ($arguments) {
|
||||
foreach ($arguments as $arg) {
|
||||
if (!$arg) {
|
||||
continue;
|
||||
}
|
||||
$selectedIds[] = $arg;
|
||||
}
|
||||
} else {
|
||||
$selectedIds = promptSnapshotsRemoval($snapshots);
|
||||
if (!$selectedIds) {
|
||||
echo "No snapshots selected. Aborting.\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$selectedIds = array_values(array_unique($selectedIds));
|
||||
echo "Snapshots selected for removal:\n";
|
||||
foreach ($selectedIds as $id) {
|
||||
echo " - {$id}\n";
|
||||
}
|
||||
|
||||
$autoConfirm = isset($options['yes']) || isset($options['y']);
|
||||
if (!$autoConfirm) {
|
||||
echo "\nThis action cannot be undone. Proceed? [y/N] ";
|
||||
$confirmation = strtolower(trim((string)fgets(STDIN)));
|
||||
if (!in_array($confirmation, ['y', 'yes'], true)) {
|
||||
echo "Aborted.\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$success = 0;
|
||||
foreach ($selectedIds as $id) {
|
||||
$result = removeSnapshot($id);
|
||||
echo $result['message'] . "\n";
|
||||
if ($result['success']) {
|
||||
$success++;
|
||||
}
|
||||
}
|
||||
|
||||
exit($success > 0 ? 0 : 1);
|
||||
|
||||
case 'apply':
|
||||
$snapshotId = $arguments[0] ?? null;
|
||||
if (!$snapshotId) {
|
||||
echo "Missing snapshot id.\n\n" . RESTORE_USAGE . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
applySnapshot($snapshotId, $options);
|
||||
break;
|
||||
|
||||
case 'snapshot':
|
||||
createManualSnapshot($options);
|
||||
break;
|
||||
|
||||
case 'recovery':
|
||||
$action = strtolower($arguments[0] ?? 'status');
|
||||
$manager = new RecoveryManager(GRAV_ROOT);
|
||||
|
||||
switch ($action) {
|
||||
case 'clear':
|
||||
if ($manager->isActive()) {
|
||||
$manager->clear();
|
||||
echo "Recovery flag cleared.\n";
|
||||
} else {
|
||||
echo "Recovery mode is not active.\n";
|
||||
}
|
||||
exit(0);
|
||||
|
||||
case 'status':
|
||||
if (!$manager->isActive()) {
|
||||
echo "Recovery mode is not active.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$context = $manager->getContext();
|
||||
if (!$context) {
|
||||
echo "Recovery flag present but context could not be parsed.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$created = isset($context['created_at']) ? date('c', (int)$context['created_at']) : 'unknown';
|
||||
$token = $context['token'] ?? '(missing)';
|
||||
$message = $context['message'] ?? '(no message)';
|
||||
$plugin = $context['plugin'] ?? '(none detected)';
|
||||
$file = $context['file'] ?? '(unknown file)';
|
||||
$line = $context['line'] ?? '(unknown line)';
|
||||
|
||||
echo "Recovery flag context:\n";
|
||||
echo " Token: {$token}\n";
|
||||
echo " Message: {$message}\n";
|
||||
echo " Plugin: {$plugin}\n";
|
||||
echo " File: {$file}\n";
|
||||
echo " Line: {$line}\n";
|
||||
echo " Created: {$created}\n";
|
||||
|
||||
$window = $manager->getUpgradeWindow();
|
||||
if ($window) {
|
||||
$expires = isset($window['expires_at']) ? date('c', (int)$window['expires_at']) : 'unknown';
|
||||
$reason = $window['reason'] ?? '(unknown)';
|
||||
echo " Window: active ({$reason}, expires {$expires})\n";
|
||||
} else {
|
||||
echo " Window: inactive\n";
|
||||
}
|
||||
exit(0);
|
||||
|
||||
default:
|
||||
echo "Unknown recovery action: {$action}\n\n" . RESTORE_USAGE . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
case 'help':
|
||||
default:
|
||||
echo RESTORE_USAGE . "\n";
|
||||
exit($command === 'help' ? 0 : 1);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Recovery\RecoveryManager;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
|
||||
if (!\defined('GRAV_ROOT')) {
|
||||
\define('GRAV_ROOT', dirname(__DIR__));
|
||||
@@ -17,28 +16,15 @@ session_start([
|
||||
|
||||
$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 ($action === 'clear-flag') {
|
||||
// Clear recovery flag - allowed without authentication
|
||||
if ($action === 'clear-flag') {
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Recovery flag cleared. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> to continue.';
|
||||
} elseif ($action === 'disable-recovery') {
|
||||
// Disable recovery mode in config (updates.recovery_mode) - allowed without authentication
|
||||
$configDir = GRAV_ROOT . '/user/config';
|
||||
$configFile = $configDir . '/system.yaml';
|
||||
Folder::create($configDir);
|
||||
@@ -47,7 +33,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (is_file($configFile)) {
|
||||
$content = file_get_contents($configFile);
|
||||
if ($content !== false) {
|
||||
// Simple YAML parsing for this specific case
|
||||
$config = \Symfony\Component\Yaml\Yaml::parse($content) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -59,68 +44,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$yaml = \Symfony\Component\Yaml\Yaml::dump($config, 4, 2);
|
||||
file_put_contents($configFile, $yaml);
|
||||
|
||||
// Also clear the recovery flag
|
||||
$manager->clear();
|
||||
$_SESSION['grav_recovery_authenticated'] = null;
|
||||
$notice = 'Recovery mode has been disabled. <a href="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '">Reload the page</a> to continue.';
|
||||
} 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.';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$errorMessage = 'Authentication required for this action.';
|
||||
}
|
||||
}
|
||||
|
||||
$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';
|
||||
$snapshots = [];
|
||||
if (is_dir($manifestDir)) {
|
||||
$files = glob($manifestDir . '/*.json');
|
||||
if ($files) {
|
||||
foreach ($files as $file) {
|
||||
$decoded = json_decode(file_get_contents($file), true);
|
||||
if (!is_array($decoded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $decoded['id'] ?? pathinfo($file, PATHINFO_FILENAME);
|
||||
if (!is_string($id) || $id === '' || strncmp($id, 'snapshot-', 9) !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded['id'] = $id;
|
||||
$decoded['file'] = basename($file);
|
||||
$decoded['created_at'] = (int)($decoded['created_at'] ?? filemtime($file) ?: 0);
|
||||
$snapshots[] = $decoded;
|
||||
}
|
||||
|
||||
if ($snapshots) {
|
||||
usort($snapshots, static function (array $a, array $b): int {
|
||||
return ($b['created_at'] ?? 0) <=> ($a['created_at'] ?? 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$latestSnapshot = $snapshots[0] ?? null;
|
||||
|
||||
// Determine base URL for assets
|
||||
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
|
||||
$baseUrl = rtrim(dirname($scriptName), '/\\');
|
||||
@@ -333,24 +261,6 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
color: #e8e8e8;
|
||||
word-break: break-word;
|
||||
}
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.help-text {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
@@ -363,39 +273,6 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.quarantine-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.quarantine-list li {
|
||||
padding: 12px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quarantine-list .plugin-name {
|
||||
font-weight: 600;
|
||||
color: #fcd34d;
|
||||
}
|
||||
.quarantine-list .plugin-time {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.snapshot-info {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.snapshot-info code {
|
||||
color: #60a5fa;
|
||||
}
|
||||
.snapshot-info small {
|
||||
color: #64748b;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
body { padding: 12px; }
|
||||
.card { padding: 16px; }
|
||||
@@ -470,23 +347,6 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($quarantine): ?>
|
||||
<div class="card">
|
||||
<h2>Quarantined Plugins</h2>
|
||||
<p class="help-text" style="margin-top: 0;">These plugins have been automatically disabled due to errors:</p>
|
||||
<ul class="quarantine-list">
|
||||
<?php foreach ($quarantine as $entry): ?>
|
||||
<li>
|
||||
<span class="plugin-name"><?php echo htmlspecialchars($entry['slug'], ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
<span class="plugin-time">Disabled at <?php echo date('Y-m-d H:i:s', $entry['disabled_at']); ?></span>
|
||||
<?php if (!empty($entry['message'])): ?>
|
||||
<div style="margin-top: 4px; font-size: 0.85rem; color: #94a3b8;"><?php echo htmlspecialchars($entry['message'], ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>What would you like to do?</h2>
|
||||
@@ -508,40 +368,6 @@ header('Content-Type: text/html; charset=utf-8');
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if ($latestSnapshot): ?>
|
||||
<div class="card">
|
||||
<h2>Rollback to Previous Version</h2>
|
||||
<p style="margin-top: 0; color: #94a3b8;">If the error persists, you can rollback to a previous Grav version.</p>
|
||||
|
||||
<div class="snapshot-info">
|
||||
<code><?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?></code>
|
||||
<?php if (!empty($latestSnapshot['label'])): ?>
|
||||
<small><?php echo htmlspecialchars($latestSnapshot['label'], ENT_QUOTES, 'UTF-8'); ?></small>
|
||||
<?php endif; ?>
|
||||
<small>Grav <?php echo htmlspecialchars($latestSnapshot['target_version'] ?? 'unknown', ENT_QUOTES, 'UTF-8'); ?> — Created <?php echo date('Y-m-d H:i:s', (int)$latestSnapshot['created_at']); ?></small>
|
||||
</div>
|
||||
|
||||
<?php if (!$authenticated): ?>
|
||||
<p class="help-text">To rollback, enter the recovery token found in <code>user/data/recovery.flag</code></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" placeholder="Enter token from recovery.flag" required>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-secondary">Authenticate for Rollback</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="rollback">
|
||||
<input type="hidden" name="manifest" value="<?php echo htmlspecialchars($latestSnapshot['id'], ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-danger">Rollback to This Snapshot</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -117,15 +117,53 @@ class Upgrader
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the currently installed Grav is upgradable to a newer version
|
||||
* Checks if the currently installed Grav is upgradable to a newer version.
|
||||
*
|
||||
* @return bool True if it's upgradable, False otherwise.
|
||||
* Returns false when the remote version is from a different major.minor family
|
||||
* (e.g. local is 1.8.x and remote is 2.0.x), so that installs are never
|
||||
* silently jumped across a major boundary.
|
||||
*
|
||||
* @return bool True if it's upgradable within the same major.minor family.
|
||||
*/
|
||||
public function isUpgradable()
|
||||
{
|
||||
if ($this->isCrossFamilyUpgrade()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return version_compare($this->getLocalVersion(), $this->getRemoteVersion(), '<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a newer major version is available (e.g. currently on 1.x, remote offers 2.x).
|
||||
* Intended for informational notices — does not imply an automatic upgrade will occur.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isNextMajorAvailable(): bool
|
||||
{
|
||||
$localMajor = (int) explode('.', $this->getLocalVersion())[0];
|
||||
$remoteMajor = (int) explode('.', $this->getRemoteVersion())[0];
|
||||
|
||||
return $remoteMajor > $localMajor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the remote version belongs to a different major.minor family than the local version.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function isCrossFamilyUpgrade(): bool
|
||||
{
|
||||
$localParts = explode('.', $this->getLocalVersion());
|
||||
$remoteParts = explode('.', $this->getRemoteVersion());
|
||||
|
||||
$localFamily = ($localParts[0] ?? '0') . '.' . ($localParts[1] ?? '0');
|
||||
$remoteFamily = ($remoteParts[0] ?? '0') . '.' . ($remoteParts[1] ?? '0');
|
||||
|
||||
return $localFamily !== $remoteFamily;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Grav is currently symbolically linked
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ 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;
|
||||
@@ -39,7 +38,6 @@ class GpmApplication extends Application
|
||||
new UpdateCommand(),
|
||||
new SelfupgradeCommand(),
|
||||
new PreflightCommand(),
|
||||
new RollbackCommand(),
|
||||
new DirectInstallCommand(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ use Grav\Console\Cli\NewProjectCommand;
|
||||
use Grav\Console\Cli\PageSystemValidatorCommand;
|
||||
use Grav\Console\Cli\SandboxCommand;
|
||||
use Grav\Console\Cli\SchedulerCommand;
|
||||
use Grav\Console\Cli\SafeUpgradeRunCommand;
|
||||
use Grav\Console\Cli\SecurityCommand;
|
||||
use Grav\Console\Cli\ServerCommand;
|
||||
use Grav\Console\Cli\YamlLinterCommand;
|
||||
@@ -50,7 +49,6 @@ class GravApplication extends Application
|
||||
new YamlLinterCommand(),
|
||||
new ServerCommand(),
|
||||
new PageSystemValidatorCommand(),
|
||||
new SafeUpgradeRunCommand(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Console\Cli
|
||||
*
|
||||
* Background worker for Safe Upgrade jobs.
|
||||
*/
|
||||
|
||||
namespace Grav\Console\Cli;
|
||||
|
||||
use Grav\Console\GravCommand;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
class SafeUpgradeRunCommand extends GravCommand
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('safe-upgrade:run')
|
||||
->setDescription('Execute a queued Grav safe-upgrade job')
|
||||
->addOption(
|
||||
'job',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Job identifier to execute'
|
||||
);
|
||||
}
|
||||
|
||||
protected function serve(): int
|
||||
{
|
||||
$input = $this->getInput();
|
||||
/** @var SymfonyStyle $io */
|
||||
$io = $this->getIO();
|
||||
|
||||
$jobId = $input->getOption('job');
|
||||
if (!$jobId) {
|
||||
$io->error('Missing required --job option.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (method_exists($this, 'initializePlugins')) {
|
||||
$this->initializePlugins();
|
||||
}
|
||||
|
||||
if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) {
|
||||
$path = GRAV_ROOT . '/user/plugins/admin/classes/plugin/SafeUpgradeManager.php';
|
||||
if (is_file($path)) {
|
||||
require_once $path;
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists(\Grav\Plugin\Admin\SafeUpgradeManager::class)) {
|
||||
$io->error('SafeUpgradeManager is not available. Ensure the Admin plugin is installed.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$manager = new \Grav\Plugin\Admin\SafeUpgradeManager();
|
||||
$manifest = $manager->loadJob($jobId);
|
||||
|
||||
if (!$manifest) {
|
||||
$io->error(sprintf('Safe upgrade job "%s" could not be found.', $jobId));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$options = $manifest['options'] ?? [];
|
||||
$manager->updateJob([
|
||||
'status' => 'running',
|
||||
'started_at' => $manifest['started_at'] ?? time(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$operation = $options['operation'] ?? 'upgrade';
|
||||
if ($operation === 'restore') {
|
||||
$result = $manager->runRestore($options);
|
||||
} else {
|
||||
$result = $manager->run($options);
|
||||
}
|
||||
$manager->ensureJobResult($result);
|
||||
|
||||
return ($result['status'] ?? null) === 'success' ? 0 : 1;
|
||||
} catch (Throwable $e) {
|
||||
$manager->ensureJobResult([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
namespace Grav\Console\Gpm;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
use Grav\Console\GpmCommand;
|
||||
use Grav\Installer\Install;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use function json_encode;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
@@ -22,8 +21,7 @@ class PreflightCommand extends GpmCommand
|
||||
protected function serve(): int
|
||||
{
|
||||
$io = $this->getIO();
|
||||
$service = $this->createSafeUpgradeService();
|
||||
$report = $service->preflight();
|
||||
$report = Install::instance()->generatePreflightReport();
|
||||
|
||||
$hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['monolog_conflicts']) || !empty($report['warnings']);
|
||||
|
||||
@@ -81,21 +79,4 @@ class PreflightCommand extends GpmCommand
|
||||
|
||||
return $hasIssues ? 2 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SafeUpgradeService
|
||||
*/
|
||||
protected function createSafeUpgradeService(): SafeUpgradeService
|
||||
{
|
||||
$config = null;
|
||||
try {
|
||||
$config = Grav::instance()['config'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$config = null;
|
||||
}
|
||||
|
||||
return new SafeUpgradeService([
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ use Grav\Common\GPM\Installer;
|
||||
use Grav\Common\GPM\Upgrader;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Console\GpmCommand;
|
||||
// NOTE: SafeUpgradeService removed - no longer used in this file
|
||||
// Preflight is now handled in Install.php after downloading the package
|
||||
use Grav\Installer\Install;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
@@ -98,18 +96,6 @@ class SelfupgradeCommand extends GpmCommand
|
||||
'Option to set the timeout in seconds when downloading the update (0 for no timeout)',
|
||||
30
|
||||
)
|
||||
->addOption(
|
||||
'safe',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Force safe upgrade staging even if disabled in configuration'
|
||||
)
|
||||
->addOption(
|
||||
'legacy',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Force legacy in-place upgrade even if safe upgrade is enabled'
|
||||
)
|
||||
->setDescription('Detects and performs an update of Grav itself when available')
|
||||
->setHelp('The <info>update</info> command updates Grav itself when a new version is available');
|
||||
}
|
||||
@@ -121,39 +107,8 @@ class SelfupgradeCommand extends GpmCommand
|
||||
{
|
||||
$input = $this->getInput();
|
||||
$io = $this->getIO();
|
||||
$forceSafe = (bool) $input->getOption('safe');
|
||||
$forceLegacy = (bool) $input->getOption('legacy');
|
||||
$forcedMode = null;
|
||||
|
||||
if ($forceSafe && $forceLegacy) {
|
||||
$io->error('Cannot force safe and legacy upgrade modes simultaneously.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($forceSafe || $forceLegacy) {
|
||||
$forcedMode = $forceSafe ? true : false;
|
||||
// NOTE: Do not call Install::forceSafeUpgrade() here as it would load the old Install class
|
||||
// before the upgrade package is extracted, causing a class redeclaration error.
|
||||
// Instead, we set the config and also use an environment variable as a fallback.
|
||||
putenv('GRAV_FORCE_SAFE_UPGRADE=' . ($forcedMode ? '1' : '0'));
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['config'])) {
|
||||
$grav['config']->set('system.updates.safe_upgrade', $forcedMode);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore container bootstrap failures; mode override still applies via env var.
|
||||
}
|
||||
if ($forceSafe) {
|
||||
$io->note('Safe upgrade staging forced for this run.');
|
||||
} else {
|
||||
$io->warning('Legacy in-place upgrade forced for this run.');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
$io->title('GPM Self Upgrade');
|
||||
$io->error('php-zip extension needs to be enabled!');
|
||||
|
||||
@@ -167,10 +122,6 @@ class SelfupgradeCommand extends GpmCommand
|
||||
|
||||
$this->displayGPMRelease();
|
||||
|
||||
// NOTE: Preflight checks are now run in Install.php AFTER downloading the package.
|
||||
// This ensures we use the NEW SafeUpgradeService from the package, not the old one.
|
||||
// Running preflight here would load the OLD class into memory and prevent the new one from loading.
|
||||
|
||||
$update = $this->upgrader->getAssets()['grav-update'];
|
||||
|
||||
$local = $this->upgrader->getLocalVersion();
|
||||
@@ -276,13 +227,6 @@ class SelfupgradeCommand extends GpmCommand
|
||||
$io->newLine();
|
||||
$io->writeln("Preparing to upgrade to v<cyan>{$remote}</cyan>..");
|
||||
|
||||
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
|
||||
$recovery = Grav::instance()['recovery'];
|
||||
$recovery->markUpgradeWindow('core-upgrade', [
|
||||
'scope' => 'core',
|
||||
'target_version' => $remote,
|
||||
]);
|
||||
|
||||
$io->write(" |- Downloading upgrade [{$this->formatBytes($update['size'])}]... 0%");
|
||||
$this->file = $this->download($update);
|
||||
|
||||
@@ -297,47 +241,7 @@ class SelfupgradeCommand extends GpmCommand
|
||||
$error = 1;
|
||||
} else {
|
||||
$io->writeln(" |- <green>Success!</green> ");
|
||||
|
||||
$manifest = Install::instance()->getLastManifest();
|
||||
if (is_array($manifest) && ($manifest['id'] ?? null)) {
|
||||
$snapshotId = (string) $manifest['id'];
|
||||
$snapshotTimestamp = isset($manifest['created_at']) ? (int) $manifest['created_at'] : null;
|
||||
$manifestPath = null;
|
||||
if (isset($manifest['id'])) {
|
||||
$manifestPath = 'user/data/upgrades/' . $manifest['id'] . '.json';
|
||||
}
|
||||
$metadata = [
|
||||
'scope' => 'core',
|
||||
'target_version' => $remote,
|
||||
'snapshot' => $snapshotId,
|
||||
];
|
||||
if (null !== $snapshotTimestamp) {
|
||||
$metadata['snapshot_created_at'] = $snapshotTimestamp;
|
||||
}
|
||||
if ($manifestPath) {
|
||||
$metadata['snapshot_manifest'] = $manifestPath;
|
||||
}
|
||||
|
||||
$recovery->markUpgradeWindow('core-upgrade', $metadata);
|
||||
|
||||
$io->writeln(sprintf(" |- Recovery snapshot: <cyan>%s</cyan>", $snapshotId));
|
||||
if (null !== $snapshotTimestamp) {
|
||||
$io->writeln(sprintf(" |- Snapshot captured: <white>%s</white>", date('c', $snapshotTimestamp)));
|
||||
}
|
||||
if ($manifestPath) {
|
||||
$io->writeln(sprintf(" |- Manifest stored at: <white>%s</white>", $manifestPath));
|
||||
}
|
||||
} else {
|
||||
// Ensure recovery window remains active even if manifest could not be resolved.
|
||||
$recovery->markUpgradeWindow('core-upgrade', [
|
||||
'scope' => 'core',
|
||||
'target_version' => $remote,
|
||||
]);
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
// Clear recovery flag - upgrade completed successfully
|
||||
$recovery->closeUpgradeWindow();
|
||||
}
|
||||
|
||||
if ($this->tmp && is_dir($this->tmp)) {
|
||||
@@ -345,16 +249,6 @@ class SelfupgradeCommand extends GpmCommand
|
||||
}
|
||||
|
||||
return $error;
|
||||
} finally {
|
||||
if (null !== $forcedMode) {
|
||||
// Clean up environment variable
|
||||
putenv('GRAV_FORCE_SAFE_UPGRADE');
|
||||
// Only call Install::forceSafeUpgrade if Install class has been loaded
|
||||
if (class_exists(\Grav\Installer\Install::class, false)) {
|
||||
Install::forceSafeUpgrade(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -756,7 +650,6 @@ class SelfupgradeCommand extends GpmCommand
|
||||
'bin/grav',
|
||||
'bin/plugin',
|
||||
'bin/gpm',
|
||||
'bin/restore',
|
||||
'bin/composer.phar'
|
||||
];
|
||||
|
||||
|
||||
@@ -20,43 +20,28 @@ use Grav\Common\Plugins;
|
||||
use Grav\Common\Yaml;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use function array_slice;
|
||||
use function basename;
|
||||
use function class_exists;
|
||||
use function count;
|
||||
use function date;
|
||||
use function dirname;
|
||||
use function explode;
|
||||
use function floor;
|
||||
use function function_exists;
|
||||
use function file_get_contents;
|
||||
use function glob;
|
||||
use function iterator_to_array;
|
||||
use function is_dir;
|
||||
use function is_file;
|
||||
use function is_link;
|
||||
use function method_exists;
|
||||
use function is_string;
|
||||
use function is_writable;
|
||||
use function json_encode;
|
||||
use function json_decode;
|
||||
use function readlink;
|
||||
use function array_fill_keys;
|
||||
use function array_map;
|
||||
use function array_pad;
|
||||
use function array_key_exists;
|
||||
use function rsort;
|
||||
use function sort;
|
||||
use function sprintf;
|
||||
use function strtolower;
|
||||
use function strpos;
|
||||
use function preg_match;
|
||||
use function symlink;
|
||||
use function time;
|
||||
use function uniqid;
|
||||
use function unlink;
|
||||
use const GRAV_ROOT;
|
||||
use const JSON_PRETTY_PRINT;
|
||||
|
||||
/**
|
||||
* Grav installer.
|
||||
@@ -156,19 +141,12 @@ final class Install
|
||||
/** @var VersionUpdater|null */
|
||||
private $updater;
|
||||
|
||||
/** @var array|null */
|
||||
private $lastManifest = null;
|
||||
|
||||
/** @var static */
|
||||
private static $instance;
|
||||
/** @var bool|null */
|
||||
private static $forceSafeUpgrade = null;
|
||||
/** @var bool */
|
||||
private static $allowPendingOverride = false;
|
||||
/** @var bool */
|
||||
private static $allowIncompatibleOverride = false;
|
||||
/** @var int|null */
|
||||
private static $snapshotLimit = null;
|
||||
/** @var callable|null */
|
||||
private $progressCallback = null;
|
||||
/** @var array|null */
|
||||
@@ -187,14 +165,10 @@ final class Install
|
||||
}
|
||||
|
||||
/**
|
||||
* Force safe-upgrade mode independently of system configuration.
|
||||
*
|
||||
* @param bool|null $state
|
||||
* @return void
|
||||
* @deprecated No-op stub kept for backward compatibility with pre-2.0 upgrade scripts.
|
||||
*/
|
||||
public static function forceSafeUpgrade(?bool $state = true): void
|
||||
{
|
||||
self::$forceSafeUpgrade = $state;
|
||||
}
|
||||
|
||||
private function __construct()
|
||||
@@ -370,8 +344,6 @@ ERR;
|
||||
throw new RuntimeException('Oops, installer was run without prepare()!', 500);
|
||||
}
|
||||
|
||||
$this->lastManifest = null;
|
||||
|
||||
try {
|
||||
if (null === $this->updater) {
|
||||
$versions = Versions::instance(USER_DIR . 'config/versions.yaml');
|
||||
@@ -381,7 +353,6 @@ ERR;
|
||||
// Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema.
|
||||
$this->updater->install();
|
||||
|
||||
$safeUpgradeRequested = $this->shouldUseSafeUpgrade();
|
||||
$targetVersion = $this->getVersion();
|
||||
if (null === $this->pendingPreflight) {
|
||||
$this->pendingPreflight = $this->runPreflightChecks($targetVersion);
|
||||
@@ -392,22 +363,7 @@ ERR;
|
||||
|
||||
return;
|
||||
}
|
||||
$snapshotManifest = null;
|
||||
if ($safeUpgradeRequested) {
|
||||
$snapshotManifest = $this->captureCoreSnapshot($targetVersion);
|
||||
if ($snapshotManifest) {
|
||||
$this->relayProgress('snapshot', sprintf('Snapshot %s captured.', $snapshotManifest['id']), 100);
|
||||
} else {
|
||||
$this->relayProgress('error', 'Safe upgrade requires a recovery snapshot, but snapshot capture failed.', null);
|
||||
Installer::setError('Safe upgrade requires a recovery snapshot. Ensure tmp://grav-snapshots is writable and retry.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$progressMessage = $safeUpgradeRequested
|
||||
? 'Running Grav standard installer (safe mode)...'
|
||||
: 'Running Grav standard installer...';
|
||||
$this->relayProgress('installing', $progressMessage, null);
|
||||
$this->relayProgress('installing', 'Running Grav standard installer...', null);
|
||||
|
||||
Installer::install(
|
||||
$this->zip ?? '',
|
||||
@@ -433,352 +389,6 @@ ERR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldUseSafeUpgrade(): bool
|
||||
{
|
||||
if (null !== self::$forceSafeUpgrade) {
|
||||
return self::$forceSafeUpgrade;
|
||||
}
|
||||
|
||||
$envValue = getenv('GRAV_FORCE_SAFE_UPGRADE');
|
||||
if (false !== $envValue && '' !== $envValue) {
|
||||
return $envValue === '1';
|
||||
}
|
||||
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['config'])) {
|
||||
$configValue = $grav['config']->get('system.updates.safe_upgrade');
|
||||
if ($configValue !== null) {
|
||||
return (bool) $configValue;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore bootstrap failures
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getSafeUpgradeSnapshotLimit(): int
|
||||
{
|
||||
if (null !== self::$snapshotLimit) {
|
||||
return self::$snapshotLimit;
|
||||
}
|
||||
|
||||
$limit = 5;
|
||||
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['config'])) {
|
||||
$configured = $grav['config']->get('system.updates.safe_upgrade_snapshot_limit');
|
||||
if ($configured !== null) {
|
||||
$limit = (int)$configured;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore bootstrap failures
|
||||
}
|
||||
|
||||
if ($limit < 0) {
|
||||
$limit = 0;
|
||||
}
|
||||
|
||||
self::$snapshotLimit = $limit;
|
||||
|
||||
return $limit;
|
||||
}
|
||||
|
||||
private function captureCoreSnapshot(string $targetVersion): ?array
|
||||
{
|
||||
$entries = $this->collectSnapshotEntries();
|
||||
if (!$entries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshotRoot = $this->resolveSnapshotStore();
|
||||
if (!$snapshotRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshotId = 'snapshot-' . date('YmdHis');
|
||||
$snapshotPath = $snapshotRoot . '/' . $snapshotId;
|
||||
try {
|
||||
Folder::create($snapshotPath);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to create snapshot directory: ' . $e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = count($entries);
|
||||
foreach ($entries as $index => $entry) {
|
||||
$percent = $total > 0 ? (int)floor((($index + 1) / $total) * 100) : null;
|
||||
$this->relayProgress('snapshot', sprintf('Snapshotting %s (%d/%d)', $entry, $index + 1, $total), $percent);
|
||||
|
||||
$source = GRAV_ROOT . '/' . $entry;
|
||||
$destination = $snapshotPath . '/' . $entry;
|
||||
|
||||
try {
|
||||
$this->snapshotCopyEntry($source, $destination);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Snapshot copy failed for ' . $entry . ': ' . $e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = [
|
||||
'id' => $snapshotId,
|
||||
'created_at' => time(),
|
||||
'source_version' => GRAV_VERSION,
|
||||
'target_version' => $targetVersion,
|
||||
'php_version' => PHP_VERSION,
|
||||
'entries' => $entries,
|
||||
'package_path' => null,
|
||||
'backup_path' => $snapshotPath,
|
||||
'operation' => 'upgrade',
|
||||
'mode' => 'pre-upgrade',
|
||||
];
|
||||
|
||||
$this->persistSnapshotManifest($manifest);
|
||||
$this->lastManifest = $manifest;
|
||||
$this->pruneOldSnapshots($snapshotRoot);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
private function collectSnapshotEntries(): array
|
||||
{
|
||||
$ignores = array_fill_keys($this->ignores, true);
|
||||
$ignores['user'] = true;
|
||||
|
||||
$entries = [];
|
||||
try {
|
||||
$iterator = new \DirectoryIterator(GRAV_ROOT);
|
||||
foreach ($iterator as $item) {
|
||||
if ($item->isDot()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $item->getFilename();
|
||||
if (isset($ignores[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $name;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to enumerate snapshot entries: ' . $e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
sort($entries);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function snapshotCopyEntry(string $source, string $destination): void
|
||||
{
|
||||
if (is_link($source)) {
|
||||
$linkTarget = readlink($source);
|
||||
Folder::create(dirname($destination));
|
||||
if (is_link($destination) || is_file($destination)) {
|
||||
@unlink($destination);
|
||||
}
|
||||
if ($linkTarget !== false) {
|
||||
@symlink($linkTarget, $destination);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_dir($source)) {
|
||||
Folder::rcopy($source, $destination);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Folder::create(dirname($destination));
|
||||
if (!@copy($source, $destination)) {
|
||||
throw new RuntimeException(sprintf('Failed to copy file %s during snapshot.', $source));
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveSnapshotStore(): ?string
|
||||
{
|
||||
$candidates = [];
|
||||
try {
|
||||
$grav = Grav::instance();
|
||||
if ($grav && isset($grav['locator'])) {
|
||||
$path = $grav['locator']->findResource('tmp://grav-snapshots', true, true);
|
||||
if ($path) {
|
||||
$candidates[] = $path;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore locator issues
|
||||
}
|
||||
$candidates[] = GRAV_ROOT . '/tmp/grav-snapshots';
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (!$candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Folder::create($candidate);
|
||||
} catch (\Throwable $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_dir($candidate) && is_writable($candidate)) {
|
||||
return rtrim($candidate, '\\/');
|
||||
}
|
||||
}
|
||||
|
||||
error_log('[Grav Upgrade] Unable to locate writable snapshot directory; skipping snapshot.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistSnapshotManifest(array $manifest): void
|
||||
{
|
||||
$store = GRAV_ROOT . '/user/data/upgrades';
|
||||
|
||||
try {
|
||||
Folder::create($store);
|
||||
$path = $store . '/' . $manifest['id'] . '.json';
|
||||
@file_put_contents($path, json_encode($manifest, JSON_PRETTY_PRINT));
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to write snapshot manifest: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function pruneOldSnapshots(?string $snapshotRoot): void
|
||||
{
|
||||
$limit = $this->getSafeUpgradeSnapshotLimit();
|
||||
if ($limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
||||
$files = glob($manifestDir . '/*.json');
|
||||
if (!$files) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manifests = [];
|
||||
foreach ($files as $path) {
|
||||
$decoded = null;
|
||||
try {
|
||||
$contents = @file_get_contents($path);
|
||||
if ($contents !== false) {
|
||||
$candidate = json_decode($contents, true);
|
||||
if (is_array($candidate)) {
|
||||
$decoded = $candidate;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$decoded = null;
|
||||
}
|
||||
|
||||
if (!$decoded || !$this->isSnapshotManifest($decoded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifests[] = [
|
||||
'path' => $path,
|
||||
'manifest' => $decoded,
|
||||
'created_at' => (int)($decoded['created_at'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($manifests) <= $limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
\usort($manifests, static function (array $a, array $b): int {
|
||||
$delta = $b['created_at'] <=> $a['created_at'];
|
||||
if ($delta !== 0) {
|
||||
return $delta;
|
||||
}
|
||||
|
||||
return strcmp((string)$b['path'], (string)$a['path']);
|
||||
});
|
||||
|
||||
$obsolete = array_slice($manifests, $limit);
|
||||
$removed = 0;
|
||||
|
||||
foreach ($obsolete as $entry) {
|
||||
$manifestPath = (string)$entry['path'];
|
||||
$manifest = $entry['manifest'] ?? null;
|
||||
|
||||
$snapshotId = $manifest['id'] ?? basename($manifestPath, '.json');
|
||||
$backupPath = $manifest['backup_path'] ?? null;
|
||||
|
||||
if ($backupPath && is_dir($backupPath)) {
|
||||
try {
|
||||
Folder::delete($backupPath);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to delete snapshot directory ' . $backupPath . ': ' . $e->getMessage());
|
||||
}
|
||||
} elseif ($snapshotRoot && $snapshotId) {
|
||||
$candidate = $snapshotRoot . '/' . $snapshotId;
|
||||
if (is_dir($candidate)) {
|
||||
try {
|
||||
Folder::delete($candidate);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Grav Upgrade] Unable to delete snapshot directory ' . $candidate . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!@unlink($manifestPath)) {
|
||||
error_log('[Grav Upgrade] Unable to remove snapshot manifest: ' . $manifestPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
$removed++;
|
||||
}
|
||||
|
||||
if ($removed > 0) {
|
||||
$this->relayProgress(
|
||||
'snapshot',
|
||||
sprintf(
|
||||
'Pruned %d old snapshot%s (keeping latest %d).',
|
||||
$removed,
|
||||
$removed === 1 ? '' : 's',
|
||||
$limit
|
||||
),
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function isSnapshotManifest(array $manifest): bool
|
||||
{
|
||||
$id = isset($manifest['id']) ? (string)$manifest['id'] : '';
|
||||
if ($id === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strpos($id, 'snapshot-') !== 0 && strpos($id, 'upgrade-') !== 0 && strpos($id, 'stage-') !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$backupPath = $manifest['backup_path'] ?? null;
|
||||
if (!is_string($backupPath) || $backupPath === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return is_array($manifest['entries'] ?? null);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @throws RuntimeException
|
||||
@@ -912,7 +522,6 @@ ERR;
|
||||
'bin/grav',
|
||||
'bin/plugin',
|
||||
'bin/gpm',
|
||||
'bin/restore',
|
||||
'bin/composer.phar'
|
||||
];
|
||||
|
||||
@@ -933,11 +542,11 @@ ERR;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null
|
||||
* @deprecated Always returns null; snapshots removed in 2.0.
|
||||
*/
|
||||
public function getLastManifest(): ?array
|
||||
{
|
||||
return $this->lastManifest;
|
||||
return null;
|
||||
}
|
||||
|
||||
public function generatePreflightReport(): array
|
||||
|
||||
124
tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php
Normal file
124
tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Grav\Common\GPM;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Testable subclass that bypasses the GravCore HTTP constructor so we can
|
||||
* inject arbitrary local/remote version strings without any HTTP calls.
|
||||
*/
|
||||
class TestableUpgrader extends Upgrader
|
||||
{
|
||||
private string $localVersion;
|
||||
private string $remoteVersion;
|
||||
|
||||
public function __construct(string $local, string $remote)
|
||||
{
|
||||
// Intentionally skip parent constructor — no HTTP, no Grav bootstrap needed.
|
||||
$this->localVersion = $local;
|
||||
$this->remoteVersion = $remote;
|
||||
}
|
||||
|
||||
public function getLocalVersion(): string
|
||||
{
|
||||
return $this->localVersion;
|
||||
}
|
||||
|
||||
public function getRemoteVersion(): string
|
||||
{
|
||||
return $this->remoteVersion;
|
||||
}
|
||||
}
|
||||
|
||||
class UpgraderFamilyTest extends TestCase
|
||||
{
|
||||
private function make(string $local, string $remote): TestableUpgrader
|
||||
{
|
||||
return new TestableUpgrader($local, $remote);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cross-family: upgrades MUST be blocked
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function testOneEightToTwoZeroIsBlocked(): void
|
||||
{
|
||||
$u = $this->make('1.8.0-beta.28', '2.0.0');
|
||||
$this->assertFalse($u->isUpgradable(), '1.8→2.0 must be blocked');
|
||||
$this->assertTrue($u->isNextMajorAvailable(), '2.0 notice must fire');
|
||||
}
|
||||
|
||||
public function testOneSevenToTwoZeroIsBlocked(): void
|
||||
{
|
||||
$u = $this->make('1.7.49', '2.0.0');
|
||||
$this->assertFalse($u->isUpgradable(), '1.7→2.0 must be blocked');
|
||||
$this->assertTrue($u->isNextMajorAvailable());
|
||||
}
|
||||
|
||||
public function testOneSevenToOneEightIsBlocked(): void
|
||||
{
|
||||
// Different minor family — should also be blocked
|
||||
$u = $this->make('1.7.49', '1.8.0-beta.1');
|
||||
$this->assertFalse($u->isUpgradable(), '1.7→1.8 must be blocked');
|
||||
$this->assertFalse($u->isNextMajorAvailable(), 'no major increment, so no notice');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Same family: upgrades MUST be allowed when remote is newer
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function testOneSevenSameFamilyUpgrade(): void
|
||||
{
|
||||
$u = $this->make('1.7.48', '1.7.49');
|
||||
$this->assertTrue($u->isUpgradable());
|
||||
$this->assertFalse($u->isNextMajorAvailable());
|
||||
}
|
||||
|
||||
public function testOneEightPrereleaseUpgrade(): void
|
||||
{
|
||||
$u = $this->make('1.8.0-beta.28', '1.8.0-beta.29');
|
||||
$this->assertTrue($u->isUpgradable());
|
||||
$this->assertFalse($u->isNextMajorAvailable());
|
||||
}
|
||||
|
||||
public function testTwoZeroSameFamilyUpgrade(): void
|
||||
{
|
||||
$u = $this->make('2.0.0', '2.0.1');
|
||||
$this->assertTrue($u->isUpgradable());
|
||||
$this->assertFalse($u->isNextMajorAvailable());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Same version: not upgradable
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function testSameVersionNotUpgradable(): void
|
||||
{
|
||||
$this->assertFalse($this->make('1.8.0-beta.28', '1.8.0-beta.28')->isUpgradable());
|
||||
$this->assertFalse($this->make('1.7.49', '1.7.49')->isUpgradable());
|
||||
$this->assertFalse($this->make('2.0.0', '2.0.0')->isUpgradable());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// isNextMajorAvailable: only fires on a true major increment
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function testNextMajorNotFiredForMinorIncrement(): void
|
||||
{
|
||||
// 1.8 → 1.9 is a different minor, not a new major
|
||||
$this->assertFalse($this->make('1.8.0', '1.9.0')->isNextMajorAvailable());
|
||||
}
|
||||
|
||||
public function testNextMajorFiredCorrectly(): void
|
||||
{
|
||||
$this->assertTrue($this->make('1.9.0', '2.0.0')->isNextMajorAvailable());
|
||||
$this->assertTrue($this->make('2.1.0', '3.0.0')->isNextMajorAvailable());
|
||||
}
|
||||
|
||||
public function testNextMajorNotFiredWhenOnTwoZero(): void
|
||||
{
|
||||
$this->assertFalse($this->make('2.0.0', '2.0.1')->isNextMajorAvailable());
|
||||
$this->assertFalse($this->make('2.0.0', '2.1.0')->isNextMajorAvailable());
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Common\Upgrade\SafeUpgradeService;
|
||||
|
||||
class SafeUpgradeServiceTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
/** @var string */
|
||||
private $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tmpDir = sys_get_temp_dir() . '/grav-safe-upgrade-' . uniqid('', true);
|
||||
Folder::create($this->tmpDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (is_dir($this->tmpDir)) {
|
||||
Folder::delete($this->tmpDir);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
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']
|
||||
];
|
||||
public $monolog = [
|
||||
'gamma' => [
|
||||
['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError(']
|
||||
]
|
||||
];
|
||||
|
||||
protected function detectPendingPluginUpdates(): array
|
||||
{
|
||||
return $this->pending;
|
||||
}
|
||||
|
||||
protected function detectPsrLogConflicts(): array
|
||||
{
|
||||
return $this->conflicts;
|
||||
}
|
||||
|
||||
protected function detectMonologConflicts(): array
|
||||
{
|
||||
return $this->monolog;
|
||||
}
|
||||
};
|
||||
|
||||
$result = $service->preflight();
|
||||
|
||||
self::assertArrayHasKey('warnings', $result);
|
||||
self::assertCount(3, $result['warnings']);
|
||||
self::assertArrayHasKey('alpha', $result['plugins_pending']);
|
||||
self::assertArrayHasKey('beta', $result['psr_log_conflicts']);
|
||||
self::assertArrayHasKey('gamma', $result['monolog_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 [];
|
||||
}
|
||||
|
||||
protected function detectMonologConflicts(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
$result = $service->preflight();
|
||||
|
||||
self::assertSame([], $result['plugins_pending']);
|
||||
self::assertSame([], $result['psr_log_conflicts']);
|
||||
self::assertSame([], $result['monolog_conflicts']);
|
||||
self::assertCount(1, $result['warnings']);
|
||||
self::assertStringContainsString('Cannot reach GPM', $result['warnings'][0]);
|
||||
}
|
||||
|
||||
public function testPromoteAndRollback(): void
|
||||
{
|
||||
[$root, $manifestStore] = $this->prepareLiveEnvironment();
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'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::assertFileExists($root . '/ORIGINAL');
|
||||
|
||||
$manifestFile = $manifestStore . '/' . $manifest['id'] . '.json';
|
||||
self::assertFileExists($manifestFile);
|
||||
|
||||
$service->rollback($manifest['id']);
|
||||
|
||||
self::assertFileExists($root . '/ORIGINAL');
|
||||
self::assertFileDoesNotExist($root . '/system/new.txt');
|
||||
|
||||
self::assertDirectoryExists($manifest['backup_path']);
|
||||
}
|
||||
|
||||
public function testPrunesOldSnapshots(): void
|
||||
{
|
||||
[$root, $manifestStore] = $this->prepareLiveEnvironment();
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'manifest_store' => $manifestStore,
|
||||
]);
|
||||
|
||||
$manifests = [];
|
||||
for ($i = 0; $i < 6; $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(5, $files);
|
||||
|
||||
// Verify the oldest one (index 0) is gone
|
||||
$oldestManifestId = $manifests[0]['id'];
|
||||
self::assertFileDoesNotExist($manifestStore . '/' . $oldestManifestId . '.json');
|
||||
self::assertDirectoryDoesNotExist($manifests[0]['backup_path']);
|
||||
|
||||
// Verify the newest one (index 5) exists
|
||||
$newestManifestId = $manifests[5]['id'];
|
||||
self::assertFileExists($manifestStore . '/' . $newestManifestId . '.json');
|
||||
self::assertDirectoryExists($manifests[5]['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,
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts');
|
||||
$method->setAccessible(true);
|
||||
$conflicts = $method->invoke($service);
|
||||
|
||||
self::assertArrayHasKey('problem', $conflicts);
|
||||
}
|
||||
|
||||
public function testDetectsMonologConflictsFromFilesystem(): void
|
||||
{
|
||||
[$root] = $this->prepareLiveEnvironment();
|
||||
$plugin = $root . '/user/plugins/logger';
|
||||
Folder::create($plugin . '/src');
|
||||
$code = <<<'PHP'
|
||||
<?php
|
||||
class LoggerTest {
|
||||
public function test(
|
||||
\Monolog\Logger $logger
|
||||
) {
|
||||
$logger->addError('deprecated');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
file_put_contents($plugin . '/src/logger.php', $code);
|
||||
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts');
|
||||
$method->setAccessible(true);
|
||||
$conflicts = $method->invoke($service);
|
||||
|
||||
self::assertArrayHasKey('logger', $conflicts);
|
||||
self::assertNotEmpty($conflicts['logger']);
|
||||
self::assertStringContainsString('addError', $conflicts['logger'][0]['method']);
|
||||
}
|
||||
|
||||
public function testClearRecoveryFlagRemovesFile(): void
|
||||
{
|
||||
[$root] = $this->prepareLiveEnvironment();
|
||||
$flag = $root . '/user/data/recovery.flag';
|
||||
$window = $root . '/user/data/recovery.window';
|
||||
Folder::create(dirname($flag));
|
||||
file_put_contents($flag, 'flag');
|
||||
Folder::create(dirname($window));
|
||||
file_put_contents($window, json_encode(['expires_at' => time() + 120]));
|
||||
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
]);
|
||||
$service->clearRecoveryFlag();
|
||||
|
||||
self::assertFileDoesNotExist($flag);
|
||||
self::assertFileExists($window);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
private function prepareLiveEnvironment(): array
|
||||
{
|
||||
$root = $this->tmpDir . '/root';
|
||||
$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, $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;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
<?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 \PHPUnit\Framework\TestCase
|
||||
{
|
||||
public function testServeOutputsJsonWhenRequested(): void
|
||||
{
|
||||
$service = new StubSafeUpgradeService([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'monolog_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']],
|
||||
'monolog_conflicts' => ['gamma' => [['file' => 'user/plugins/gamma/gamma.php', 'method' => '->addError(']]],
|
||||
'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);
|
||||
self::assertStringContainsString('gamma', $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(?string $targetVersion = null): 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);
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
<?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\Question\Question;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class RollbackCommandTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
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 $question): mixed
|
||||
{
|
||||
if ($this->responses) {
|
||||
return array_shift($this->responses);
|
||||
}
|
||||
|
||||
return parent::askQuestion($question);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user