diff --git a/CHANGELOG.md b/CHANGELOG.md index 011e025cf..d266fa39e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,15 @@ * Removed `system.umask_fix` setting for security reasons * Support phpstan level 6 in Framework classes + +# v1.7.50 +## UNRELEASED + +1. [](#new) + * Added staged self-upgrade pipeline with manifest snapshots and atomic swaps for Grav core updates. + * Introduced recovery mode with token-gated UI, plugin quarantine, and CLI rollback support. + * Added `bin/gpm preflight` compatibility scanner and `bin/gpm rollback` utility. + # v1.7.49.5 ## 09/10/2025 @@ -132,7 +141,6 @@ * Bug in `exif_read_data` [#3878](https://github.com/getgrav/grav/pull/3878) * Fix parser error in URI: [#3894](https://github.com/getgrav/grav/issues/3894) ->>>>>>> develop # v1.7.48 ## 10/28/2024 diff --git a/bin/grav-restore b/bin/grav-restore new file mode 100755 index 000000000..a8087148a --- /dev/null +++ b/bin/grav-restore @@ -0,0 +1,214 @@ +#!/usr/bin/env php + [--staging-root=/absolute/path] + Restores the specified snapshot created by safe-upgrade. + +Options: + --staging-root Overrides the staging directory (defaults to configured value). + +Examples: + bin/grav-restore list + bin/grav-restore apply stage-68eff31cc4104 + bin/grav-restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups +USAGE; + +/** + * @param array $args + * @return array{command:string,arguments:array,options:array} + */ +function parseArguments(array $args): array +{ + array_shift($args); // remove script name + + $command = $args[0] ?? 'help'; + $arguments = []; + $options = []; + + foreach (array_slice($args, 1) as $arg) { + if (strncmp($arg, '--staging-root=', 15) === 0) { + $options['staging_root'] = substr($arg, 15); + continue; + } + + if (substr($arg, 0, 2) === '--') { + echo "Unknown option: {$arg}\n"; + exit(1); + } + + $arguments[] = $arg; + } + + return [ + 'command' => $command, + 'arguments' => $arguments, + 'options' => $options, + ]; +} + +/** + * @return string|null + */ +function readConfiguredStagingRoot(): ?string +{ + $configFiles = [ + GRAV_ROOT . '/user/config/system.yaml', + GRAV_ROOT . '/system/config/system.yaml' + ]; + + foreach ($configFiles as $file) { + if (!is_file($file)) { + continue; + } + + try { + $data = Yaml::parseFile($file); + } catch (\Throwable $e) { + continue; + } + + if (!is_array($data)) { + continue; + } + + $current = $data['system']['updates']['staging_root'] ?? null; + if (null !== $current && $current !== '') { + return $current; + } + } + + return null; +} + +/** + * @param array $options + * @return SafeUpgradeService + */ +function createUpgradeService(array $options): SafeUpgradeService +{ + $config = readConfiguredStagingRoot(); + if ($config !== null && empty($options['staging_root'])) { + $options['staging_root'] = $config; + } elseif (isset($options['staging_root']) && $options['staging_root'] === '') { + unset($options['staging_root']); + } + + $options['root'] = GRAV_ROOT; + + return new SafeUpgradeService($options); +} + +/** + * @return list + */ +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'], + 'target_version' => $decoded['target_version'] ?? null, + 'created_at' => $decoded['created_at'] ?? 0, + ]; + } + + return $snapshots; +} + +$cli = parseArguments($argv); +$command = $cli['command']; +$arguments = $cli['arguments']; +$options = $cli['options']; + +switch ($command) { + 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) { + $time = $snapshot['created_at'] ? date('c', (int)$snapshot['created_at']) : 'unknown'; + $version = $snapshot['target_version'] ?? 'unknown'; + echo sprintf(" - %s (Grav %s, %s)\n", $snapshot['id'], $version, $time); + } + exit(0); + + case 'apply': + $snapshotId = $arguments[0] ?? null; + if (!$snapshotId) { + echo "Missing snapshot id.\n\n" . RESTORE_USAGE . "\n"; + exit(1); + } + + 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['target_version'] ?? 'unknown'; + echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; + exit(0); + + case 'help': + default: + echo RESTORE_USAGE . "\n"; + exit($command === 'help' ? 0 : 1); +} diff --git a/index.php b/index.php index 32a214791..177557f80 100644 --- a/index.php +++ b/index.php @@ -36,6 +36,12 @@ date_default_timezone_set(@date_default_timezone_get()); @ini_set('default_charset', 'UTF-8'); mb_internal_encoding('UTF-8'); +$recoveryFlag = __DIR__ . '/system/recovery.flag'; +if (PHP_SAPI !== 'cli' && is_file($recoveryFlag)) { + require __DIR__ . '/system/recovery.php'; + return 0; +} + use Grav\Common\Grav; use RocketTheme\Toolbox\Event\Event; diff --git a/system/UPGRADE_PROTOTYPE.md b/system/UPGRADE_PROTOTYPE.md new file mode 100644 index 000000000..2047ee284 --- /dev/null +++ b/system/UPGRADE_PROTOTYPE.md @@ -0,0 +1,49 @@ +# Grav Safe Self-Upgrade Prototype + +This document tracks the design decisions behind the new self-upgrade prototype for Grav 1.8. + +## Goals + +- Prevent in-place mutation of the running Grav tree. +- Guarantee a restorable snapshot before any destructive change. +- Detect high-risk plugin incompatibilities (eg. `psr/log`) prior to upgrading. +- Provide a recovery surface that does not depend on a working Admin plugin. + +## High-Level Flow + +1. **Preflight** + - Ensure PHP & extensions satisfy the target release requirements. + - Refresh GPM metadata and require all plugins/themes to be on their latest compatible release. + - Scan plugin `composer.json` files for dependencies that are known to break under Grav 1.8 (eg. `psr/log` < 3) and surface actionable warnings. +2. **Stage** + - Download the Grav update archive into a staging area outside the live tree (`{parent}/grav-upgrades/{timestamp}`). + - Extract the package, then write a manifest describing the target version, PHP info, and enabled packages. + - Snapshot the live `user/` directory and relevant metadata into the same stage folder. +3. **Promote** + - Switch the installation by renaming the live tree to a rollback folder and promoting the staged tree into place via atomic renames. + - Clear caches in the staged tree before promotion. + - Run Grav CLI smoke checks (`bin/grav check`) while still holding maintenance state; swap back automatically on failure. +4. **Finalize** + - Record the manifest under `user/data/upgrades`. + - Resume normal traffic by removing the maintenance flag. + - Leave the previous tree and manifest available for manual rollback commands. + +## Recovery Mode + +- Introduce a `system/recovery.flag` sentinel written whenever a fatal error occurs during bootstrap or when a promoted release fails validation. +- While the flag is present, Grav forces a minimal Recovery UI served outside of Admin, protected by a short-lived signed token. +- The Recovery UI lists recent manifests, quarantined plugins, and offers rollback/disabling actions. +- Clearing the flag requires either a successful rollback or a full Grav request cycle without fatal errors. + +## CLI Additions + +- `bin/gpm preflight grav@`: runs the same preflight checks without executing the upgrade. +- `bin/gpm rollback []`: swaps the live tree with a stored rollback snapshot. +- Existing `self-upgrade` command now wraps the stage/promote pipeline and respects the snapshot manifest. + +## Open Items + +- Finalize compatibility heuristics (initial pass focuses on `psr/log` and removed logging APIs). +- UX polish for the Recovery UI (initial prototype will expose basic actions only). +- Decide retention policy for old manifests and snapshots (prototype keeps the most recent three). + diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index 77611f655..4684d5da5 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -1580,6 +1580,30 @@ form: validate: type: bool + updates_section: + type: section + title: PLUGIN_ADMIN.UPDATES_SECTION + + updates.safe_upgrade: + type: toggle + label: PLUGIN_ADMIN.SAFE_UPGRADE + help: PLUGIN_ADMIN.SAFE_UPGRADE_HELP + highlight: 1 + default: true + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + updates.staging_root: + type: text + label: PLUGIN_ADMIN.SAFE_UPGRADE_STAGING + help: PLUGIN_ADMIN.SAFE_UPGRADE_STAGING_HELP + placeholder: '/absolute/path/to/grav-upgrades' + validate: + type: string + http_section: type: section title: PLUGIN_ADMIN.HTTP_SECTION @@ -1906,3 +1930,4 @@ form: # # pages.type: # type: hidden + diff --git a/system/config/system.yaml b/system/config/system.yaml index 09e04555c..1fcb1cf3b 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -203,6 +203,10 @@ gpm: releases: stable # Set to either 'stable' or 'testing' official_gpm_only: true # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security +updates: + safe_upgrade: true # Enable guarded staging+rollback pipeline for Grav self-updates + staging_root: '' # Optional absolute path for staging backups (default: /grav-upgrades) + http: method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL enable_proxy: true # Enable proxy server configuration diff --git a/system/languages/en.yaml b/system/languages/en.yaml index 7a8a68c00..973199ff3 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -119,3 +119,10 @@ GRAV: ERROR2: Bad number of elements ERROR3: The jquery_element should be set into jqCron settings ERROR4: Unrecognized expression + +PLUGIN_ADMIN: + UPDATES_SECTION: Updates + SAFE_UPGRADE: Safe self-upgrade + SAFE_UPGRADE_HELP: When enabled, Grav core updates use staged installation with automatic rollback support. + SAFE_UPGRADE_STAGING: Staging directory + SAFE_UPGRADE_STAGING_HELP: Optional absolute path for storing upgrade backups. Leave empty to use the default inside the parent directory. diff --git a/system/recovery.php b/system/recovery.php new file mode 100644 index 000000000..528359695 --- /dev/null +++ b/system/recovery.php @@ -0,0 +1,181 @@ + 'grav-recovery', + 'cookie_httponly' => true, + 'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'cookie_samesite' => 'Lax', +]); + +$manager = new RecoveryManager(); +$context = $manager->getContext() ?? []; +$token = $context['token'] ?? null; +$authenticated = $token && isset($_SESSION['grav_recovery_authenticated']) && hash_equals($_SESSION['grav_recovery_authenticated'], $token); +$errorMessage = null; +$notice = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + if ($action === 'authenticate') { + $provided = trim($_POST['token'] ?? ''); + if ($token && hash_equals($token, $provided)) { + $_SESSION['grav_recovery_authenticated'] = $token; + header('Location: ' . $_SERVER['REQUEST_URI']); + exit; + } + $errorMessage = 'Invalid recovery token.'; + } elseif ($authenticated) { + $service = new SafeUpgradeService(); + try { + if ($action === 'rollback' && !empty($_POST['manifest'])) { + $service->rollback(trim($_POST['manifest'])); + $manager->clear(); + $_SESSION['grav_recovery_authenticated'] = null; + $notice = 'Rollback complete. Please reload Grav.'; + } + if ($action === 'clear-flag') { + $manager->clear(); + $_SESSION['grav_recovery_authenticated'] = null; + $notice = 'Recovery flag cleared.'; + } + } catch (\Throwable $e) { + $errorMessage = $e->getMessage(); + } + } else { + $errorMessage = 'Authentication required.'; + } +} + +$quarantineFile = GRAV_ROOT . '/user/data/upgrades/quarantine.json'; +$quarantine = []; +if (is_file($quarantineFile)) { + $decoded = json_decode(file_get_contents($quarantineFile), true); + if (is_array($decoded)) { + $quarantine = $decoded; + } +} + +$manifestDir = GRAV_ROOT . '/user/data/upgrades'; +$manifests = []; +if (is_dir($manifestDir)) { + $files = glob($manifestDir . '/*.json'); + if ($files) { + rsort($files); + foreach ($files as $file) { + $decoded = json_decode(file_get_contents($file), true); + if (is_array($decoded)) { + $decoded['file'] = basename($file); + $manifests[] = $decoded; + } + } + } +} + +header('Content-Type: text/html; charset=utf-8'); + +?> + + + + + Grav Recovery Mode + + + +
+

Grav Recovery Mode

+ +
+ + +
+ + + +

This site is running in recovery mode because Grav detected a fatal error.

+

Locate the recovery token in system/recovery.flag and enter it below.

+
+ + + + +
+ +
+

Failure Details

+
    +
  • Message:
  • +
  • File:
  • +
  • Line:
  • + +
  • Quarantined plugin:
  • + +
+
+ + +
+

Quarantined Plugins

+
    + +
  • + + (disabled at )
    + +
  • + +
+
+ + +
+

Rollback

+ +
+ + + + +
+ +

No upgrade snapshots were found.

+ +
+ +
+ + +
+ +
+ + diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index 3aae05186..2c7e0aa93 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -46,6 +46,7 @@ use Grav\Common\Service\SessionServiceProvider; use Grav\Common\Service\StreamsServiceProvider; use Grav\Common\Service\TaskServiceProvider; use Grav\Common\Twig\Twig; +use Grav\Common\Recovery\RecoveryManager; use Grav\Framework\DI\Container; use Grav\Framework\Psr7\Response; use Grav\Framework\RequestHandler\Middlewares\MultipartRequestSupport; @@ -110,6 +111,7 @@ class Grav extends Container 'scheduler' => Scheduler::class, 'taxonomy' => Taxonomy::class, 'themes' => Themes::class, + 'recovery' => RecoveryManager::class, 'twig' => Twig::class, 'uri' => Uri::class, ]; diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php index 5103adc96..af8d59711 100644 --- a/system/src/Grav/Common/Processors/InitializeProcessor.php +++ b/system/src/Grav/Common/Processors/InitializeProcessor.php @@ -78,6 +78,9 @@ class InitializeProcessor extends ProcessorBase // Initialize error handlers. $this->initializeErrors(); + // Register recovery shutdown handler early in the lifecycle. + $this->container['recovery']->registerHandlers(); + // Initialize debugger. $debugger = $this->initializeDebugger(); @@ -143,6 +146,9 @@ class InitializeProcessor extends ProcessorBase // Disable debugger. $this->container['debugger']->enabled(false); + // Register recovery handler for CLI commands as well. + $this->container['recovery']->registerHandlers(); + // Set timezone, locale. $this->initializeLocale($config); diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php new file mode 100644 index 000000000..c179e4b24 --- /dev/null +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -0,0 +1,317 @@ +rootPath = rtrim($root, DIRECTORY_SEPARATOR); + $this->userPath = $this->rootPath . '/user'; + } + + /** + * Register shutdown handler to capture fatal errors at runtime. + * + * @return void + */ + public function registerHandlers(): void + { + if ($this->registered) { + return; + } + + register_shutdown_function([$this, 'handleShutdown']); + $this->registered = true; + } + + /** + * Check if recovery mode flag is active. + * + * @return bool + */ + public function isActive(): bool + { + return is_file($this->flagPath()); + } + + /** + * Remove recovery flag. + * + * @return void + */ + public function clear(): void + { + $flag = $this->flagPath(); + if (is_file($flag)) { + @unlink($flag); + } + } + + /** + * Shutdown handler capturing fatal errors. + * + * @return void + */ + public function handleShutdown(): void + { + $error = $this->resolveLastError(); + if (!$error) { + return; + } + + $type = $error['type'] ?? 0; + if (!$this->isFatal($type)) { + return; + } + + $file = $error['file'] ?? ''; + $plugin = $this->detectPluginFromPath($file); + $context = [ + 'created_at' => time(), + 'message' => $error['message'] ?? '', + 'file' => $file, + 'line' => $error['line'] ?? null, + 'type' => $type, + 'plugin' => $plugin, + ]; + + $this->activate($context); + if ($plugin) { + $this->quarantinePlugin($plugin, $context); + } + } + + /** + * Activate recovery mode and record context. + * + * @param array $context + * @return void + */ + public function activate(array $context): void + { + $flag = $this->flagPath(); + if (empty($context['token'])) { + $context['token'] = $this->generateToken(); + } + if (!is_file($flag)) { + file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } else { + // Merge context if flag already exists. + $existing = json_decode(file_get_contents($flag), true); + if (is_array($existing)) { + $context = $context + $existing; + if (empty($context['token'])) { + $context['token'] = $this->generateToken(); + } + } + file_put_contents($flag, json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + } + + /** + * Return last recorded recovery context. + * + * @return array|null + */ + public function getContext(): ?array + { + $flag = $this->flagPath(); + if (!is_file($flag)) { + return null; + } + + $decoded = json_decode(file_get_contents($flag), true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param string $slug + * @param array $context + * @return void + */ + public function disablePlugin(string $slug, array $context = []): void + { + $context += [ + 'message' => $context['message'] ?? 'Disabled during upgrade preflight', + 'file' => $context['file'] ?? '', + 'line' => $context['line'] ?? null, + 'created_at' => $context['created_at'] ?? time(), + 'plugin' => $context['plugin'] ?? $slug, + ]; + + $this->quarantinePlugin($slug, $context); + } + + /** + * @param string $slug + * @param array $context + * @return void + */ + protected function quarantinePlugin(string $slug, array $context): void + { + $slug = trim($slug); + if ($slug === '') { + return; + } + + $configPath = $this->userPath . '/config/plugins/' . $slug . '.yaml'; + Folder::create(dirname($configPath)); + + $configuration = is_file($configPath) ? Yaml::parse(file_get_contents($configPath)) : []; + if (!is_array($configuration)) { + $configuration = []; + } + + if (($configuration['enabled'] ?? true) === false) { + return; + } + + $configuration['enabled'] = false; + $yaml = Yaml::dump($configuration); + file_put_contents($configPath, $yaml); + + $quarantineFile = $this->userPath . '/data/upgrades/quarantine.json'; + Folder::create(dirname($quarantineFile)); + + $quarantine = []; + if (is_file($quarantineFile)) { + $decoded = json_decode(file_get_contents($quarantineFile), true); + if (is_array($decoded)) { + $quarantine = $decoded; + } + } + + $quarantine[$slug] = [ + 'slug' => $slug, + 'disabled_at' => time(), + 'message' => $context['message'] ?? '', + 'file' => $context['file'] ?? '', + 'line' => $context['line'] ?? null, + ]; + + file_put_contents($quarantineFile, json_encode($quarantine, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + + /** + * Determine if error type is fatal. + * + * @param int $type + * @return bool + */ + private function isFatal(int $type): bool + { + return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true); + } + + /** + * Attempt to derive plugin slug from file path. + * + * @param string $file + * @return string|null + */ + private function detectPluginFromPath(string $file): ?string + { + if (!$file) { + return null; + } + + if (preg_match('#/user/plugins/([^/]+)/#', $file, $matches)) { + return $matches[1] ?? null; + } + + return null; + } + + /** + * @return string + */ + private function flagPath(): string + { + return $this->rootPath . '/system/recovery.flag'; + } + + /** + * @return string + */ + protected function generateToken(): string + { + try { + return bin2hex($this->randomBytes(10)); + } catch (\Throwable $e) { + return md5(uniqid('grav-recovery', true)); + } + } + + /** + * @param int $length + * @return string + */ + protected function randomBytes(int $length): string + { + return random_bytes($length); + } + + /** + * @return array|null + */ + protected function resolveLastError(): ?array + { + return error_get_last(); + } +} diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php new file mode 100644 index 000000000..fbbee6714 --- /dev/null +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -0,0 +1,650 @@ +rootPath = rtrim($root, DIRECTORY_SEPARATOR); + $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); + + $candidates = []; + if (!empty($options['staging_root'])) { + $candidates[] = $options['staging_root']; + } + $candidates[] = $this->parentDir . DIRECTORY_SEPARATOR . 'grav-upgrades'; + if (getenv('HOME')) { + $candidates[] = getenv('HOME') . DIRECTORY_SEPARATOR . 'grav-upgrades'; + } + $candidates[] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'grav-upgrades'; + + $this->stagingRoot = null; + foreach ($candidates as $candidate) { + $resolved = $this->resolveStagingPath($candidate); + if ($resolved) { + $this->stagingRoot = $resolved; + break; + } + } + + if (null === $this->stagingRoot) { + throw new RuntimeException('Unable to locate writable staging directory. Configure system.updates.staging_root or adjust permissions.'); + } + $this->manifestStore = $options['manifest_store'] ?? ($this->rootPath . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'upgrades'); + if (isset($options['ignored_dirs']) && is_array($options['ignored_dirs'])) { + $this->ignoredDirs = $options['ignored_dirs']; + } + } + + /** + * Run preflight validations before attempting an upgrade. + * + * @return array{plugins_pending: array, psr_log_conflicts: array, warnings: string[]} + */ + public function preflight(): array + { + $warnings = []; + try { + $pending = $this->detectPendingPluginUpdates(); + } catch (RuntimeException $e) { + $pending = []; + $warnings[] = $e->getMessage(); + } + + $psrLogConflicts = $this->detectPsrLogConflicts(); + $monologConflicts = $this->detectMonologConflicts(); + if ($pending) { + $warnings[] = 'One or more plugins/themes are not up to date.'; + } + if ($psrLogConflicts) { + $warnings[] = 'Potential psr/log signature conflicts detected.'; + } + if ($monologConflicts) { + $warnings[] = 'Potential Monolog logger API incompatibilities detected.'; + } + + return [ + 'plugins_pending' => $pending, + 'psr_log_conflicts' => $psrLogConflicts, + 'monolog_conflicts' => $monologConflicts, + 'warnings' => $warnings, + ]; + } + + /** + * Stage and promote a Grav update from an extracted folder. + * + * @param string $extractedPath Path to the extracted update package. + * @param string $targetVersion Target Grav version. + * @param array $ignores + * @return array Manifest data. + */ + public function promote(string $extractedPath, string $targetVersion, array $ignores): array + { + if (!is_dir($extractedPath)) { + throw new InvalidArgumentException(sprintf('Extracted package path "%s" is not a directory.', $extractedPath)); + } + + $stageId = uniqid('stage-', false); + $stagePath = $this->stagingRoot . DIRECTORY_SEPARATOR . $stageId; + $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; + $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rollback-' . $stageId; + + Folder::create($packagePath); + + // Copy extracted package into staging area. + Folder::rcopy($extractedPath, $packagePath); + + // Ensure ignored directories are replaced with live copies. + $this->hydrateIgnoredDirectories($packagePath, $ignores); + + $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath); + $manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json'; + Folder::create(dirname($manifestPath)); + file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT)); + + // Promote staged package into place. + $this->promoteStagedTree($packagePath, $backupPath); + $this->persistManifest($manifest); + $this->pruneOldSnapshots(); + Folder::delete($stagePath); + + return $manifest; + } + + /** + * Roll back to the most recent snapshot. + * + * @param string|null $id + * @return array|null + */ + public function rollback(?string $id = null): ?array + { + $manifest = $this->resolveManifest($id); + if (!$manifest) { + return null; + } + + $backupPath = $manifest['backup_path'] ?? null; + if (!$backupPath || !is_dir($backupPath)) { + throw new RuntimeException('Rollback snapshot is no longer available.'); + } + + // Put the current tree aside before flip. + $rotated = $this->rotateCurrentTree(); + + $this->promoteBackup($backupPath); + $this->syncGitDirectory($rotated, $this->rootPath); + $this->markRollback($manifest['id']); + if ($rotated && is_dir($rotated)) { + Folder::delete($rotated); + } + + return $manifest; + } + + /** + * @return void + */ + public function clearRecoveryFlag(): void + { + $flag = $this->rootPath . '/system/recovery.flag'; + if (is_file($flag)) { + @unlink($flag); + } + } + + /** + * @return array + */ + protected function detectPendingPluginUpdates(): array + { + try { + $gpm = new GPM(); + } catch (Throwable $e) { + throw new RuntimeException('Unable to query GPM: ' . $e->getMessage(), 0, $e); + } + $updates = $gpm->getUpdatable(['plugins' => true, 'themes' => true]); + $pending = []; + foreach ($updates as $type => $packages) { + if (!is_array($packages)) { + continue; + } + foreach ($packages as $slug => $package) { + $pending[$slug] = [ + 'type' => $type, + 'current' => $package->version ?? null, + 'available' => $package->available ?? null, + ]; + } + } + + return $pending; + } + + /** + * Check plugins for psr/log requirements that conflict with Grav 1.8 vendor stack. + * + * @return array + */ + protected function detectPsrLogConflicts(): array + { + $conflicts = []; + $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + foreach ($pluginRoots as $path) { + $composerFile = $path . '/composer.json'; + if (!is_file($composerFile)) { + continue; + } + + $json = json_decode(file_get_contents($composerFile), true); + if (!is_array($json)) { + continue; + } + + $slug = basename($path); + $rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null); + if (!$rawConstraint) { + continue; + } + + $constraint = strtolower((string)$rawConstraint); + $compatible = $constraint === '*' + || false !== strpos($constraint, '3') + || false !== strpos($constraint, '4') + || (false !== strpos($constraint, '>=') && preg_match('/>=\s*3/', $constraint)); + + if ($compatible) { + continue; + } + + $conflicts[$slug] = [ + 'composer' => $composerFile, + 'requires' => $rawConstraint, + ]; + } + + return $conflicts; + } + + /** + * Detect usage of deprecated Monolog `add*` methods removed in newer releases. + * + * @return array + */ + protected function detectMonologConflicts(): array + { + $conflicts = []; + $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + $pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i'; + + foreach ($pluginRoots as $path) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile() || strtolower($file->getExtension()) !== 'php') { + continue; + } + + $contents = @file_get_contents($file->getPathname()); + if ($contents === false) { + continue; + } + + if (preg_match($pattern, $contents, $match)) { + $slug = basename($path); + $relative = str_replace($this->rootPath . '/', '', $file->getPathname()); + $conflicts[$slug][] = [ + 'file' => $relative, + 'method' => trim($match[0]), + ]; + } + } + } + + return $conflicts; + } + + /** + * Ensure directories flagged for ignoring get hydrated from the current installation. + * + * @param string $packagePath + * @param array $ignores + * @return void + */ + private function hydrateIgnoredDirectories(string $packagePath, array $ignores): void + { + $strategic = $ignores ?: $this->ignoredDirs; + + foreach ($strategic as $relative) { + $relative = trim($relative, '/'); + if ($relative === '') { + continue; + } + + $live = $this->rootPath . '/' . $relative; + $stage = $packagePath . '/' . $relative; + + Folder::delete($stage); + + if (!is_dir($live)) { + continue; + } + + // Skip caches to avoid stale data. + if (in_array($relative, ['cache', 'tmp'], true)) { + Folder::create($stage); + continue; + } + + Folder::create(dirname($stage)); + Folder::rcopy($live, $stage); + } + } + + /** + * Build manifest metadata for a staged upgrade. + * + * @param string $stageId + * @param string $targetVersion + * @param string $packagePath + * @param string $backupPath + * @return array + */ + private function buildManifest(string $stageId, string $targetVersion, string $packagePath, string $backupPath): array + { + $plugins = []; + $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + foreach ($pluginRoots as $path) { + $slug = basename($path); + $blueprint = $path . '/blueprints.yaml'; + $details = [ + 'version' => null, + 'name' => $slug, + ]; + + if (is_file($blueprint)) { + try { + $yaml = Yaml::parse(file_get_contents($blueprint)); + if (isset($yaml['version'])) { + $details['version'] = $yaml['version']; + } + if (isset($yaml['name'])) { + $details['name'] = $yaml['name']; + } + } catch (\RuntimeException $e) { + // ignore parse errors, keep defaults + } + } + + $plugins[$slug] = $details; + } + + return [ + 'id' => $stageId, + 'created_at' => time(), + 'source_version' => GRAV_VERSION, + 'target_version' => $targetVersion, + 'php_version' => PHP_VERSION, + 'package_path' => $packagePath, + 'backup_path' => $backupPath, + 'plugins' => $plugins, + ]; + } + + /** + * Promote staged package by swapping directory names. + * + * @param string $packagePath + * @param string $backupPath + * @return void + */ + private function promoteStagedTree(string $packagePath, string $backupPath): void + { + $liveRoot = $this->rootPath; + Folder::create(dirname($backupPath)); + + if (!rename($liveRoot, $backupPath)) { + throw new RuntimeException('Failed to move current Grav directory into backup.'); + } + + if (!rename($packagePath, $liveRoot)) { + // Attempt to restore live tree. + rename($backupPath, $liveRoot); + throw new RuntimeException('Failed to promote staged Grav release.'); + } + + $this->syncGitDirectory($backupPath, $liveRoot); + } + + /** + * Move existing tree aside to allow rollback swap. + * + * @return void + */ + private function rotateCurrentTree(): string + { + $liveRoot = $this->rootPath; + $target = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rotated-' . time(); + Folder::create($this->stagingRoot); + if (!rename($liveRoot, $target)) { + throw new RuntimeException('Unable to rotate live tree during rollback.'); + } + + return $target; + } + + /** + * Promote a backup tree into the live position. + * + * @param string $backupPath + * @return void + */ + private function promoteBackup(string $backupPath): void + { + if (!rename($backupPath, $this->rootPath)) { + throw new RuntimeException('Rollback failed: unable to move backup into live position.'); + } + } + + /** + * Ensure Git metadata is retained after stage promotion. + * + * @param string $source + * @param string $destination + * @return void + */ + private function syncGitDirectory(string $source, string $destination): void + { + if (!$source || !$destination) { + return; + } + + $sourceGit = rtrim($source, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.git'; + if (!is_dir($sourceGit)) { + return; + } + + $destinationGit = rtrim($destination, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '.git'; + if (is_dir($destinationGit)) { + Folder::delete($destinationGit); + } + + Folder::rcopy($sourceGit, $destinationGit); + } + + /** + * Persist manifest into Grav data directory. + * + * @param array $manifest + * @return void + */ + private function persistManifest(array $manifest): void + { + Folder::create($this->manifestStore); + $target = $this->manifestStore . DIRECTORY_SEPARATOR . $manifest['id'] . '.json'; + file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT)); + } + + /** + * Ensure directory exists and is writable. + * + * @param string $path + * @return bool + */ + private function resolveStagingPath(?string $path): ?string + { + if (null === $path || $path === '') { + return null; + } + + $expanded = $path; + if (0 === strpos($expanded, '~')) { + $home = getenv('HOME'); + if ($home) { + $expanded = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($expanded, '~\/'); + } + } + if (!$this->isAbsolutePath($expanded)) { + $expanded = $this->rootPath . DIRECTORY_SEPARATOR . ltrim($expanded, DIRECTORY_SEPARATOR); + } + + $expanded = $this->normalizePath($expanded); + + try { + Folder::create($expanded); + } catch (\RuntimeException $e) { + return null; + } + + return is_writable($expanded) ? $expanded : null; + } + + private function isAbsolutePath(string $path): bool + { + if ($path === '') { + return false; + } + + if ($path[0] === '/' || $path[0] === '\\') { + return true; + } + + return (bool)preg_match('#^[A-Za-z]:[\\/]#', $path); + } + + private function normalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + $path = preg_replace('#/+#', '/', $path); + $path = rtrim($path, '/'); + + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + /** + * @param string|null $id + * @return array|null + */ + private function resolveManifest(?string $id): ?array + { + $path = null; + + if ($id) { + $candidate = $this->manifestStore . DIRECTORY_SEPARATOR . $id . '.json'; + if (!is_file($candidate)) { + return null; + } + $path = $candidate; + } else { + $files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: []; + if (!$files) { + return null; + } + rsort($files); + $path = $files[0]; + } + + $decoded = json_decode(file_get_contents($path), true); + + return $decoded ?: null; + } + + /** + * Record rollback event in manifest store. + * + * @param string $id + * @return void + */ + private function markRollback(string $id): void + { + $target = $this->manifestStore . DIRECTORY_SEPARATOR . $id . '.json'; + if (!is_file($target)) { + return; + } + + $manifest = json_decode(file_get_contents($target), true); + if (!is_array($manifest)) { + return; + } + + $manifest['rolled_back_at'] = time(); + file_put_contents($target, json_encode($manifest, JSON_PRETTY_PRINT)); + } + + /** + * Keep only the three newest snapshots. + * + * @return void + */ + private function pruneOldSnapshots(): void + { + $files = glob($this->manifestStore . DIRECTORY_SEPARATOR . '*.json') ?: []; + if (count($files) <= 3) { + return; + } + + sort($files); + $excess = array_slice($files, 0, count($files) - 3); + foreach ($excess as $file) { + $data = json_decode(file_get_contents($file), true); + if (isset($data['backup_path']) && is_dir($data['backup_path'])) { + Folder::delete($data['backup_path']); + } + @unlink($file); + } + } +} diff --git a/system/src/Grav/Console/Application/GpmApplication.php b/system/src/Grav/Console/Application/GpmApplication.php index 61e73d23b..4dc6f779d 100644 --- a/system/src/Grav/Console/Application/GpmApplication.php +++ b/system/src/Grav/Console/Application/GpmApplication.php @@ -13,6 +13,8 @@ use Grav\Console\Gpm\DirectInstallCommand; use Grav\Console\Gpm\IndexCommand; use Grav\Console\Gpm\InfoCommand; use Grav\Console\Gpm\InstallCommand; +use Grav\Console\Gpm\PreflightCommand; +use Grav\Console\Gpm\RollbackCommand; use Grav\Console\Gpm\SelfupgradeCommand; use Grav\Console\Gpm\UninstallCommand; use Grav\Console\Gpm\UpdateCommand; @@ -36,6 +38,8 @@ class GpmApplication extends Application new UninstallCommand(), new UpdateCommand(), new SelfupgradeCommand(), + new PreflightCommand(), + new RollbackCommand(), new DirectInstallCommand(), ]); } diff --git a/system/src/Grav/Console/Gpm/PreflightCommand.php b/system/src/Grav/Console/Gpm/PreflightCommand.php new file mode 100644 index 000000000..50a33331d --- /dev/null +++ b/system/src/Grav/Console/Gpm/PreflightCommand.php @@ -0,0 +1,92 @@ +setName('preflight') + ->addOption('json', null, InputOption::VALUE_NONE, 'Output report as JSON') + ->setDescription('Run Grav upgrade preflight checks without modifying the installation.'); + } + + protected function serve(): int + { + $io = $this->getIO(); + $service = $this->createSafeUpgradeService(); + $report = $service->preflight(); + + $hasIssues = !empty($report['plugins_pending']) || !empty($report['psr_log_conflicts']) || !empty($report['monolog_conflicts']) || !empty($report['warnings']); + + if ($this->getInput()->getOption('json')) { + $io->writeln(json_encode($report, JSON_PRETTY_PRINT)); + + return $hasIssues ? 2 : 0; + } + + $io->title('Grav Upgrade Preflight'); + + if (!empty($report['warnings'])) { + $io->writeln('Warnings'); + foreach ($report['warnings'] as $warning) { + $io->writeln(' - ' . $warning); + } + $io->newLine(); + } + + if (!empty($report['plugins_pending'])) { + $io->writeln('Packages pending update'); + foreach ($report['plugins_pending'] as $slug => $info) { + $io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $info['type'] ?? 'plugin', $info['current'] ?? 'unknown', $info['available'] ?? 'unknown')); + } + $io->newLine(); + } + + if (!empty($report['psr_log_conflicts'])) { + $io->writeln('Potential psr/log conflicts'); + foreach ($report['psr_log_conflicts'] as $slug => $info) { + $io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $info['requires'] ?? '*')); + } + $io->writeln(' › Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.'); + $io->newLine(); + } + + if (!empty($report['monolog_conflicts'])) { + $io->writeln('Potential Monolog logger conflicts'); + foreach ($report['monolog_conflicts'] as $slug => $entries) { + foreach ($entries as $entry) { + $file = $entry['file'] ?? 'unknown file'; + $method = $entry['method'] ?? 'add*'; + $io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file)); + } + } + $io->writeln(' › Update the plugin to use PSR-3 style logger calls (e.g. $logger->error()).'); + $io->newLine(); + } + + if (!$hasIssues) { + $io->success('No blocking issues detected.'); + } else { + $io->warning('Resolve the findings above before upgrading Grav.'); + } + + return $hasIssues ? 2 : 0; + } + + /** + * @return SafeUpgradeService + */ + protected function createSafeUpgradeService(): SafeUpgradeService + { + return new SafeUpgradeService(); + } +} diff --git a/system/src/Grav/Console/Gpm/RollbackCommand.php b/system/src/Grav/Console/Gpm/RollbackCommand.php new file mode 100644 index 000000000..ffe60fc0b --- /dev/null +++ b/system/src/Grav/Console/Gpm/RollbackCommand.php @@ -0,0 +1,135 @@ +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('Available snapshots:'); + 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 + */ + 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(); + } +} diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 9dfdb7fb6..80ac80420 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -15,13 +15,16 @@ use Grav\Common\HTTP\Response; use Grav\Common\GPM\Installer; use Grav\Common\GPM\Upgrader; use Grav\Common\Grav; +use Grav\Common\Upgrade\SafeUpgradeService; use Grav\Console\GpmCommand; use Grav\Installer\Install; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; use ZipArchive; +use function count; use function is_callable; use function strlen; @@ -108,6 +111,12 @@ class SelfupgradeCommand extends GpmCommand $this->displayGPMRelease(); + $safeUpgrade = $this->createSafeUpgradeService(); + $preflight = $safeUpgrade->preflight(); + if (!$this->handlePreflightReport($preflight)) { + return 1; + } + $update = $this->upgrader->getAssets()['grav-update']; $local = $this->upgrader->getLocalVersion(); @@ -225,6 +234,7 @@ class SelfupgradeCommand extends GpmCommand } else { $io->writeln(" '- Success! "); $io->newLine(); + $safeUpgrade->clearRecoveryFlag(); } if ($this->tmp && is_dir($this->tmp)) { @@ -261,6 +271,160 @@ class SelfupgradeCommand extends GpmCommand return $this->tmp . DS . $package['name']; } + /** + * @return SafeUpgradeService + */ + protected function createSafeUpgradeService(): SafeUpgradeService + { + $config = null; + try { + $config = Grav::instance()['config'] ?? null; + } catch (\Throwable $e) { + $config = null; + } + + $stagingRoot = $config ? $config->get('system.updates.staging_root') : null; + + return new SafeUpgradeService([ + 'staging_root' => $stagingRoot, + ]); + } + + /** + * @param array $preflight + * @return bool + */ + protected function handlePreflightReport(array $preflight): bool + { + $io = $this->getIO(); + $pending = $preflight['plugins_pending'] ?? []; + $conflicts = $preflight['psr_log_conflicts'] ?? []; + $monologConflicts = $preflight['monolog_conflicts'] ?? []; + $warnings = $preflight['warnings'] ?? []; + + if (empty($pending) && empty($conflicts) && empty($monologConflicts)) { + return true; + } + + if ($warnings) { + $io->newLine(); + $io->writeln('Preflight warnings detected:'); + foreach ($warnings as $warning) { + $io->writeln(' • ' . $warning); + } + } + + if ($pending) { + $io->newLine(); + $io->writeln('The following packages need updating before Grav upgrade:'); + foreach ($pending as $slug => $info) { + $type = $info['type'] ?? 'plugin'; + $current = $info['current'] ?? 'unknown'; + $available = $info['available'] ?? 'unknown'; + $io->writeln(sprintf(' - %s (%s) %s → %s', $slug, $type, $current, $available)); + } + + $io->writeln(' › Please run `bin/gpm update` to bring these packages current before upgrading Grav.'); + $io->writeln('Aborting self-upgrade. Run `bin/gpm update` first.'); + + return false; + } + + $handled = $this->handleConflicts( + $conflicts, + static function (SymfonyStyle $io, array $conflicts): void { + $io->newLine(); + $io->writeln('Potential psr/log incompatibilities:'); + foreach ($conflicts as $slug => $info) { + $requires = $info['requires'] ?? '*'; + $io->writeln(sprintf(' - %s (requires psr/log %s)', $slug, $requires)); + } + }, + 'Update the plugin or add "replace": {"psr/log": "*"} to its composer.json and reinstall dependencies.', + 'Aborting self-upgrade. Adjust composer requirements or update affected plugins.', + 'Proceeding with potential psr/log incompatibilities still active.', + 'Disabled before upgrade because of psr/log conflict' + ); + + if (!$handled) { + return false; + } + + $handledMonolog = $this->handleConflicts( + $monologConflicts, + static function (SymfonyStyle $io, array $conflicts): void { + $io->newLine(); + $io->writeln('Potential Monolog logger API incompatibilities:'); + foreach ($conflicts as $slug => $entries) { + foreach ($entries as $entry) { + $file = $entry['file'] ?? 'unknown file'; + $method = $entry['method'] ?? 'add*'; + $io->writeln(sprintf(' - %s (%s in %s)', $slug, $method, $file)); + } + } + }, + 'Update the plugin to use PSR-3 style logger methods (e.g. $logger->error()) before upgrading.', + 'Aborting self-upgrade. Update plugins to remove deprecated Monolog add* calls.', + 'Proceeding with potential Monolog API incompatibilities still active.', + 'Disabled before upgrade because of Monolog API conflict' + ); + + if (!$handledMonolog) { + return false; + } + + return true; + } + + /** + * @param array $conflicts + * @param callable $printer + * @param string $advice + * @param string $abortMessage + * @param string $continueMessage + * @param string $disableNote + * @return bool + */ + private function handleConflicts(array $conflicts, callable $printer, string $advice, string $abortMessage, string $continueMessage, string $disableNote): bool + { + if (empty($conflicts)) { + return true; + } + + $io = $this->getIO(); + $printer($io, $conflicts); + $io->writeln(' › ' . $advice); + + $choice = $this->all_yes ? 'abort' : $io->choice( + 'How would you like to proceed?', + ['disable', 'continue', 'abort'], + 'abort' + ); + + if ($choice === 'abort') { + $io->writeln($abortMessage); + + return false; + } + + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + + if ($choice === 'disable') { + foreach (array_keys($conflicts) as $slug) { + $recovery->disablePlugin($slug, ['message' => $disableNote]); + $io->writeln(sprintf(' - Disabled plugin %s.', $slug)); + } + $io->writeln('Continuing with conflicted plugins disabled.'); + + return true; + } + + $io->writeln($continueMessage); + + return true; + } + /** * @return bool */ diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 0db7036b9..794e6f67e 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -15,6 +15,7 @@ use Grav\Common\Cache; use Grav\Common\GPM\Installer; use Grav\Common\Grav; use Grav\Common\Plugins; +use Grav\Common\Upgrade\SafeUpgradeService; use RuntimeException; use function class_exists; use function dirname; @@ -260,13 +261,29 @@ ERR; // Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema. $this->updater->install(); - Installer::install( - $this->zip ?? '', - GRAV_ROOT, - ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], - $this->location, - !($this->zip && is_file($this->zip)) - ); + if ($this->shouldUseSafeUpgrade()) { + $options = []; + try { + $grav = Grav::instance(); + if ($grav && isset($grav['config'])) { + $options['staging_root'] = $grav['config']->get('system.updates.staging_root'); + } + } catch (\Throwable $e) { + // ignore + } + + $service = new SafeUpgradeService($options); + $service->promote($this->location, $this->getVersion(), $this->ignores); + Installer::setError(Installer::OK); + } else { + Installer::install( + $this->zip ?? '', + GRAV_ROOT, + ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores], + $this->location, + !($this->zip && is_file($this->zip)) + ); + } } catch (Exception $e) { Installer::setError($e->getMessage()); } @@ -280,6 +297,27 @@ ERR; } } + /** + * @return bool + */ + private function shouldUseSafeUpgrade(): bool + { + if (!class_exists(SafeUpgradeService::class)) { + return false; + } + + try { + $grav = Grav::instance(); + if ($grav && isset($grav['config'])) { + return (bool) $grav['config']->get('system.updates.safe_upgrade', true); + } + } catch (\Throwable $e) { + // Grav container may not be initialised yet, default to safe upgrade. + } + + return true; + } + /** * @return void * @throws RuntimeException diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php new file mode 100644 index 000000000..f7e7e7375 --- /dev/null +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -0,0 +1,144 @@ +tmpDir = sys_get_temp_dir() . '/grav-recovery-' . uniqid('', true); + Folder::create($this->tmpDir); + Folder::create($this->tmpDir . '/user'); + Folder::create($this->tmpDir . '/system'); + } + + protected function _after(): void + { + if (is_dir($this->tmpDir)) { + Folder::delete($this->tmpDir); + } + } + + public function testHandleShutdownQuarantinesPluginAndCreatesFlag(): void + { + $plugin = $this->tmpDir . '/user/plugins/bad'; + Folder::create($plugin); + file_put_contents($plugin . '/plugin.php', 'tmpDir) extends RecoveryManager { + protected $error; + public function __construct(string $rootPath) + { + parent::__construct($rootPath); + $this->error = [ + 'type' => E_ERROR, + 'file' => $this->getRootPath() . '/user/plugins/bad/plugin.php', + 'message' => 'Fatal failure', + 'line' => 42, + ]; + } + + public function getRootPath(): string + { + $prop = new \ReflectionProperty(RecoveryManager::class, 'rootPath'); + $prop->setAccessible(true); + + return $prop->getValue($this); + } + + protected function resolveLastError(): ?array + { + return $this->error; + } + }; + + $manager->handleShutdown(); + + $flag = $this->tmpDir . '/system/recovery.flag'; + self::assertFileExists($flag); + $context = json_decode(file_get_contents($flag), true); + self::assertSame('Fatal failure', $context['message']); + self::assertSame('bad', $context['plugin']); + self::assertNotEmpty($context['token']); + + $configFile = $this->tmpDir . '/user/config/plugins/bad.yaml'; + self::assertFileExists($configFile); + self::assertStringContainsString('enabled: false', file_get_contents($configFile)); + + $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; + self::assertFileExists($quarantine); + $decoded = json_decode(file_get_contents($quarantine), true); + self::assertArrayHasKey('bad', $decoded); + } + + public function testHandleShutdownIgnoresNonFatalErrors(): void + { + $manager = new class($this->tmpDir) extends RecoveryManager { + protected function resolveLastError(): ?array + { + return ['type' => E_USER_WARNING, 'message' => 'Notice']; + } + }; + + $manager->handleShutdown(); + + self::assertFileDoesNotExist($this->tmpDir . '/system/recovery.flag'); + } + + public function testClearRemovesFlag(): void + { + $flag = $this->tmpDir . '/system/recovery.flag'; + file_put_contents($flag, 'flag'); + + $manager = new RecoveryManager($this->tmpDir); + $manager->clear(); + + self::assertFileDoesNotExist($flag); + } + + public function testGenerateTokenFallbackOnRandomFailure(): void + { + $manager = new class($this->tmpDir) extends RecoveryManager { + protected function randomBytes(int $length): string + { + throw new \RuntimeException('No randomness'); + } + }; + + $manager->activate([]); + $context = $manager->getContext(); + + self::assertNotEmpty($context['token']); + } + + public function testGetContextWithoutFlag(): void + { + $manager = new RecoveryManager($this->tmpDir); + self::assertNull($manager->getContext()); + } + + public function testDisablePluginRecordsQuarantineWithoutFlag(): void + { + $plugin = $this->tmpDir . '/user/plugins/problem'; + Folder::create($plugin); + + $manager = new RecoveryManager($this->tmpDir); + $manager->disablePlugin('problem', ['message' => 'Manual disable']); + + $flag = $this->tmpDir . '/system/recovery.flag'; + self::assertFileDoesNotExist($flag); + + $configFile = $this->tmpDir . '/user/config/plugins/problem.yaml'; + self::assertFileExists($configFile); + self::assertStringContainsString('enabled: false', file_get_contents($configFile)); + + $quarantine = $this->tmpDir . '/user/data/upgrades/quarantine.json'; + self::assertFileExists($quarantine); + $decoded = json_decode(file_get_contents($quarantine), true); + self::assertSame('Manual disable', $decoded['problem']['message']); + } +} diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php new file mode 100644 index 000000000..b69bef31e --- /dev/null +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -0,0 +1,240 @@ +tmpDir = sys_get_temp_dir() . '/grav-safe-upgrade-' . uniqid('', true); + Folder::create($this->tmpDir); + } + + protected function _after(): void + { + if (is_dir($this->tmpDir)) { + Folder::delete($this->tmpDir); + } + } + + public function testPreflightAggregatesWarnings(): void + { + $service = new class(['root' => $this->tmpDir]) extends SafeUpgradeService { + public $pending = [ + 'alpha' => ['type' => 'plugins', 'current' => '1.0.0', 'available' => '1.1.0'] + ]; + public $conflicts = [ + 'beta' => ['requires' => '^1.0'] + ]; + 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, $staging, $manifestStore] = $this->prepareLiveEnvironment(); + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $staging, + 'manifest_store' => $manifestStore, + ]); + + $package = $this->preparePackage(); + $manifest = $service->promote($package, '1.8.0', ['backup', 'cache', 'images', 'logs', 'tmp', 'user']); + + self::assertFileExists($root . '/system/new.txt'); + self::assertFileDoesNotExist($root . '/ORIGINAL'); + + $manifestFile = $manifestStore . '/' . $manifest['id'] . '.json'; + self::assertFileExists($manifestFile); + + $service->rollback($manifest['id']); + + self::assertFileExists($root . '/ORIGINAL'); + self::assertFileDoesNotExist($root . '/system/new.txt'); + + $rotated = glob($staging . '/rotated-*'); + self::assertEmpty($rotated); + } + + public function testPrunesOldSnapshots(): void + { + [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $staging, + 'manifest_store' => $manifestStore, + ]); + + $manifests = []; + for ($i = 0; $i < 4; $i++) { + $package = $this->preparePackage((string)$i); + $manifests[] = $service->promote($package, '1.8.' . $i, ['backup', 'cache', 'images', 'logs', 'tmp', 'user']); + // Ensure subsequent promotions have a marker to restore. + file_put_contents($root . '/ORIGINAL', 'state-' . $i); + } + + $files = glob($manifestStore . '/*.json'); + self::assertCount(3, $files); + self::assertFalse(is_dir($manifests[0]['backup_path'])); + } + + public function testDetectsPsrLogConflictsFromFilesystem(): void + { + [$root] = $this->prepareLiveEnvironment(); + $plugin = $root . '/user/plugins/problem'; + Folder::create($plugin); + file_put_contents($plugin . '/composer.json', json_encode(['require' => ['psr/log' => '^1.0']], JSON_PRETTY_PRINT)); + + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', + ]); + + $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); + $method->setAccessible(true); + $conflicts = $method->invoke($service); + + self::assertArrayHasKey('problem', $conflicts); + } + + public function testDetectsMonologConflictsFromFilesystem(): void + { + [$root] = $this->prepareLiveEnvironment(); + $plugin = $root . '/user/plugins/logger'; + Folder::create($plugin . '/src'); + $code = <<<'PHP' +addError('deprecated'); + } +} +PHP; + file_put_contents($plugin . '/src/logger.php', $code); + + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', + ]); + + $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 . '/system/recovery.flag'; + Folder::create(dirname($flag)); + file_put_contents($flag, 'flag'); + + $service = new SafeUpgradeService([ + 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', + ]); + $service->clearRecoveryFlag(); + + self::assertFileDoesNotExist($flag); + } + + /** + * @return array{0:string,1:string,2:string} + */ + private function prepareLiveEnvironment(): array + { + $root = $this->tmpDir . '/root'; + $staging = $this->tmpDir . '/staging'; + $manifestStore = $root . '/user/data/upgrades'; + + Folder::create($root . '/user/plugins/sample'); + Folder::create($root . '/system'); + file_put_contents($root . '/system/original.txt', 'original'); + file_put_contents($root . '/ORIGINAL', 'original-root'); + file_put_contents($root . '/user/plugins/sample/blueprints.yaml', "name: Sample Plugin\nversion: 1.0.0\n"); + file_put_contents($root . '/user/plugins/sample/composer.json', json_encode(['require' => ['php' => '^8.0']], JSON_PRETTY_PRINT)); + + return [$root, $staging, $manifestStore]; + } + + /** + * @param string $suffix + * @return string + */ + private function preparePackage(string $suffix = ''): string + { + $package = $this->tmpDir . '/package-' . uniqid('', true); + Folder::create($package . '/system'); + Folder::create($package . '/user'); + file_put_contents($package . '/index.php', 'new-release' . $suffix); + file_put_contents($package . '/system/new.txt', 'release' . $suffix); + + return $package; + } +} diff --git a/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php new file mode 100644 index 000000000..85b1fb6eb --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/PreflightCommandTest.php @@ -0,0 +1,150 @@ + [], + '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(): array + { + return $this->report; + } +} + +class PreflightMemoryStyle extends SymfonyStyle +{ + /** @var array */ + 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); + } +} diff --git a/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php b/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php new file mode 100644 index 000000000..5a7807417 --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/RollbackCommandTest.php @@ -0,0 +1,244 @@ +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 */ + private $snapshots = []; + /** @var array */ + 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 */ + public $messages = []; + /** @var array */ + private $responses; + + public function __construct(InputInterface $input, BufferedOutput $output, array $responses = []) + { + parent::__construct($input, $output); + $this->responses = $responses; + } + + public function newLine($count = 1): void + { + for ($i = 0; $i < $count; $i++) { + $this->messages[] = ''; + } + parent::newLine($count); + } + + public function writeln($messages, $type = self::OUTPUT_NORMAL): void + { + foreach ((array)$messages as $message) { + $this->messages[] = (string)$message; + } + parent::writeln($messages, $type); + } + + public function error($message): void + { + $this->messages[] = 'error:' . $message; + parent::error($message); + } + + public function success($message): void + { + $this->messages[] = 'success:' . $message; + parent::success($message); + } + + public function askQuestion($question) + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::askQuestion($question); + } +} diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php new file mode 100644 index 000000000..0542f0870 --- /dev/null +++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php @@ -0,0 +1,213 @@ +injectIo($command); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => [], + 'warnings' => [] + ]); + + self::assertTrue($result); + self::assertSame([], $style->messages); + } + + public function testHandlePreflightReportFailsWhenPendingEvenWithAllYes(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command); + $this->setAllYes($command, true); + + $result = $command->runHandle([ + 'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], + 'psr_log_conflicts' => ['bar' => ['requires' => '^1.0']], + 'warnings' => ['pending'] + ]); + + self::assertFalse($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Run `bin/gpm update` first', $output); + } + + public function testHandlePreflightReportAbortsOnPendingWhenDeclined(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command); + $this->setAllYes($command, false); + + $result = $command->runHandle([ + 'plugins_pending' => ['foo' => ['type' => 'plugin', 'current' => '1', 'available' => '2']], + 'psr_log_conflicts' => [], + 'warnings' => [] + ]); + + self::assertFalse($result); + self::assertStringContainsString('Run `bin/gpm update` first', implode("\n", $style->messages)); + } + + public function testHandlePreflightReportAbortsOnConflictWhenDeclined(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['abort']); + $this->setAllYes($command, false); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertFalse($result); + self::assertStringContainsString('Adjust composer requirements', implode("\n", $style->messages)); + } + + public function testHandlePreflightReportDisablesPluginsWhenRequested(): void + { + $gravFactory = Fixtures::get('grav'); + $grav = $gravFactory(); + $stub = new class { + public $disabled = []; + public function disablePlugin(string $slug, array $context = []): void + { + $this->disabled[] = $slug; + } + }; + $grav['recovery'] = $stub; + + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['disable']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertTrue($result); + self::assertSame(['foo'], $stub->disabled); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Continuing with conflicted plugins disabled.', $output); + } + + public function testHandlePreflightReportContinuesWhenRequested(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['continue']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => ['foo' => ['requires' => '^1.0']], + 'warnings' => [] + ]); + + self::assertTrue($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Proceeding with potential psr/log incompatibilities still active.', $output); + } + + /** + * @param TestSelfupgradeCommand $command + * @param array $responses + * @return array{0:SelfUpgradeMemoryStyle,1:InputInterface} + */ + private function injectIo(TestSelfupgradeCommand $command, array $responses = []): array + { + $input = new ArrayInput([]); + $buffer = new BufferedOutput(); + $style = new SelfUpgradeMemoryStyle($input, $buffer, $responses); + + $this->setProtectedProperty($command, 'input', $input); + $this->setProtectedProperty($command, 'output', $style); + + $input->bind($command->getDefinition()); + + return [$style, $input]; + } + + private function setAllYes(SelfupgradeCommand $command, bool $value): void + { + $ref = new \ReflectionProperty(SelfupgradeCommand::class, 'all_yes'); + $ref->setAccessible(true); + $ref->setValue($command, $value); + } + + private function setProtectedProperty(object $object, string $property, $value): void + { + $ref = new \ReflectionProperty(\Grav\Console\GpmCommand::class, $property); + $ref->setAccessible(true); + $ref->setValue($object, $value); + } +} + +class TestSelfupgradeCommand extends SelfupgradeCommand +{ + public function runHandle(array $report): bool + { + return $this->handlePreflightReport($report); + } +} + +class SelfUpgradeMemoryStyle extends SymfonyStyle +{ + /** @var array */ + public $messages = []; + /** @var array */ + private $responses; + + /** + * @param InputInterface $input + * @param BufferedOutput $output + * @param array $responses + */ + public function __construct(InputInterface $input, BufferedOutput $output, array $responses = []) + { + parent::__construct($input, $output); + $this->responses = $responses; + } + + public function newLine($count = 1): void + { + for ($i = 0; $i < $count; $i++) { + $this->messages[] = ''; + } + parent::newLine($count); + } + + public function writeln($messages, $type = self::OUTPUT_NORMAL): void + { + foreach ((array)$messages as $message) { + $this->messages[] = (string)$message; + } + parent::writeln($messages, $type); + } + + public function askQuestion($question) + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::askQuestion($question); + } + + public function choice($question, array $choices, $default = null, $attempts = null, $errorMessage = 'Invalid value.') + { + if ($this->responses) { + return array_shift($this->responses); + } + + return parent::choice($question, $choices, $default, $attempts, $errorMessage); + } +}