From 7192cfe5495be0dcf317acc9613adfa69fffc700 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 08:09:47 -0600 Subject: [PATCH 01/18] synced restore changes Signed-off-by: Andy Miller --- bin/{grav-restore => restore} | 63 ++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) rename bin/{grav-restore => restore} (70%) diff --git a/bin/grav-restore b/bin/restore similarity index 70% rename from bin/grav-restore rename to bin/restore index a8087148a..e949471fe 100755 --- a/bin/grav-restore +++ b/bin/restore @@ -25,6 +25,7 @@ if (!file_exists($root . '/index.php')) { exit(1); } +use Grav\Common\Recovery\RecoveryManager; use Grav\Common\Upgrade\SafeUpgradeService; use Symfony\Component\Yaml\Yaml; @@ -32,19 +33,24 @@ const RESTORE_USAGE = << [--staging-root=/absolute/path] + bin/restore apply [--staging-root=/absolute/path] Restores the specified snapshot created by safe-upgrade. + bin/restore recovery [status|clear] + Shows the recovery flag context or clears it. + 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 + bin/restore list + bin/restore apply stage-68eff31cc4104 + bin/restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups + bin/restore recovery status + bin/restore recovery clear USAGE; /** @@ -207,6 +213,53 @@ switch ($command) { echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; exit(0); + 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"; + exit(0); + + default: + echo "Unknown recovery action: {$action}\n\n" . RESTORE_USAGE . "\n"; + exit(1); + } + case 'help': default: echo RESTORE_USAGE . "\n"; From c56d24c0d72fec73109b4a2b8a5cf4d73d1156e9 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 09:08:53 -0600 Subject: [PATCH 02/18] timelimt on recovery status Signed-off-by: Andy Miller --- bin/restore | 9 ++ .../Grav/Common/Recovery/RecoveryManager.php | 118 ++++++++++++++++++ .../Grav/Console/Gpm/SelfupgradeCommand.php | 7 ++ system/src/Grav/Console/Gpm/UpdateCommand.php | 5 + .../Common/Recovery/RecoveryManagerTest.php | 1 + 5 files changed, 140 insertions(+) diff --git a/bin/restore b/bin/restore index e949471fe..6ef0cfc9f 100755 --- a/bin/restore +++ b/bin/restore @@ -253,6 +253,15 @@ switch ($command) { 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: diff --git a/system/src/Grav/Common/Recovery/RecoveryManager.php b/system/src/Grav/Common/Recovery/RecoveryManager.php index c179e4b24..75691b9e5 100644 --- a/system/src/Grav/Common/Recovery/RecoveryManager.php +++ b/system/src/Grav/Common/Recovery/RecoveryManager.php @@ -20,6 +20,7 @@ use function is_array; use function is_file; use function json_decode; use function json_encode; +use function max; use function md5; use function preg_match; use function random_bytes; @@ -100,6 +101,8 @@ class RecoveryManager if (is_file($flag)) { @unlink($flag); } + + $this->closeUpgradeWindow(); } /** @@ -121,6 +124,10 @@ class RecoveryManager $file = $error['file'] ?? ''; $plugin = $this->detectPluginFromPath($file); + if (!$plugin) { + return; + } + $context = [ 'created_at' => time(), 'message' => $error['message'] ?? '', @@ -130,6 +137,10 @@ class RecoveryManager 'plugin' => $plugin, ]; + if (!$this->shouldEnterRecovery($context)) { + return; + } + $this->activate($context); if ($plugin) { $this->quarantinePlugin($plugin, $context); @@ -286,6 +297,63 @@ class RecoveryManager return $this->rootPath . '/system/recovery.flag'; } + /** + * @return string + */ + private function windowPath(): string + { + return $this->rootPath . '/system/recovery.window'; + } + + /** + * @return array|null + */ + private function resolveUpgradeWindow(): ?array + { + $path = $this->windowPath(); + if (!is_file($path)) { + return null; + } + + $decoded = json_decode(file_get_contents($path), true); + if (!is_array($decoded)) { + @unlink($path); + + return null; + } + + $expiresAt = (int)($decoded['expires_at'] ?? 0); + if ($expiresAt > 0 && $expiresAt < time()) { + @unlink($path); + + return null; + } + + return $decoded; + } + + /** + * @param array $context + * @return bool + */ + private function shouldEnterRecovery(array $context): bool + { + $window = $this->resolveUpgradeWindow(); + if (null === $window) { + return false; + } + + $scope = $window['scope'] ?? null; + if ($scope === 'plugin') { + $expected = $window['plugin'] ?? null; + if ($expected && ($context['plugin'] ?? null) !== $expected) { + return false; + } + } + + return true; + } + /** * @return string */ @@ -314,4 +382,54 @@ class RecoveryManager { return error_get_last(); } + + /** + * Begin an upgrade window; during this window fatal plugin errors may trigger recovery mode. + * + * @param string $reason + * @param array $metadata + * @param int $ttlSeconds + * @return void + */ + public function markUpgradeWindow(string $reason, array $metadata = [], int $ttlSeconds = 604800): void + { + $ttl = max(60, $ttlSeconds); + $createdAt = time(); + + $payload = $metadata + [ + 'reason' => $reason, + 'created_at' => $createdAt, + 'expires_at' => $createdAt + $ttl, + ]; + + file_put_contents($this->windowPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + } + + /** + * @return bool + */ + public function isUpgradeWindowActive(): bool + { + return $this->resolveUpgradeWindow() !== null; + } + + /** + * @return array|null + */ + public function getUpgradeWindow(): ?array + { + return $this->resolveUpgradeWindow(); + } + + /** + * @return void + */ + public function closeUpgradeWindow(): void + { + $window = $this->windowPath(); + if (is_file($window)) { + @unlink($window); + } + } + } diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index e2eac0acf..ea42f4b3d 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -222,6 +222,13 @@ class SelfupgradeCommand extends GpmCommand $io->newLine(); $io->writeln("Preparing to upgrade to v{$remote}.."); + /** @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); diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index 6e20c9391..897026f67 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -12,6 +12,7 @@ namespace Grav\Console\Gpm; use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; use Grav\Common\GPM\Upgrader; +use Grav\Common\Grav; use Grav\Console\GpmCommand; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; @@ -212,6 +213,10 @@ class UpdateCommand extends GpmCommand } } + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + $recovery->markUpgradeWindow('package-update', ['scope' => 'core']); + // finally update $install_command = $this->getApplication()->find('install'); diff --git a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php index f7e7e7375..1c21cc67d 100644 --- a/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php +++ b/tests/unit/Grav/Common/Recovery/RecoveryManagerTest.php @@ -56,6 +56,7 @@ class RecoveryManagerTest extends \Codeception\TestCase\Test } }; + $manager->markUpgradeWindow('core-upgrade', ['scope' => 'core']); $manager->handleShutdown(); $flag = $this->tmpDir . '/system/recovery.flag'; From d6cbc263e706010389c15c2138c4ed2afa82b9a3 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 11:56:40 -0600 Subject: [PATCH 03/18] source fix in restore bin + missing dot files after upgrade Signed-off-by: Andy Miller --- bin/restore | 9 ++-- .../Common/Upgrade/SafeUpgradeService.php | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/bin/restore b/bin/restore index e949471fe..93b11b017 100755 --- a/bin/restore +++ b/bin/restore @@ -139,7 +139,7 @@ function createUpgradeService(array $options): SafeUpgradeService } /** - * @return list + * @return list */ function loadSnapshots(): array { @@ -160,6 +160,7 @@ function loadSnapshots(): array $snapshots[] = [ 'id' => $decoded['id'], + 'source_version' => $decoded['source_version'] ?? null, 'target_version' => $decoded['target_version'] ?? null, 'created_at' => $decoded['created_at'] ?? 0, ]; @@ -184,8 +185,8 @@ switch ($command) { 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); + $restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown'; + echo sprintf(" - %s (restore to Grav %s, %s)\n", $snapshot['id'], $restoreVersion, $time); } exit(0); @@ -209,7 +210,7 @@ switch ($command) { exit(1); } - $version = $manifest['target_version'] ?? 'unknown'; + $version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown'; echo "Restored snapshot {$snapshotId} (Grav {$version}).\n"; exit(0); diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index fbbee6714..a7385ac29 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -9,6 +9,7 @@ namespace Grav\Common\Upgrade; +use DirectoryIterator; use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\GPM; use Grav\Common\Yaml; @@ -19,6 +20,7 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use FilesystemIterator; use function basename; +use function copy; use function count; use function dirname; use function file_get_contents; @@ -165,6 +167,8 @@ class SafeUpgradeService // Copy extracted package into staging area. Folder::rcopy($extractedPath, $packagePath); + $this->carryOverRootDotfiles($packagePath); + // Ensure ignored directories are replaced with live copies. $this->hydrateIgnoredDirectories($packagePath, $ignores); @@ -375,6 +379,49 @@ class SafeUpgradeService } } + /** + * Preserve critical root-level dotfiles that may not ship in update packages. + * + * @param string $packagePath + * @return void + */ + private function carryOverRootDotfiles(string $packagePath): void + { + $skip = [ + '.git', + '.DS_Store', + ]; + + $iterator = new DirectoryIterator($this->rootPath); + foreach ($iterator as $entry) { + if ($entry->isDot()) { + continue; + } + + $name = $entry->getFilename(); + if ($name === '' || $name[0] !== '.') { + continue; + } + + if (in_array($name, $skip, true)) { + continue; + } + + $target = $packagePath . DIRECTORY_SEPARATOR . $name; + if (file_exists($target)) { + continue; + } + + $source = $entry->getPathname(); + if ($entry->isDir()) { + Folder::rcopy($source, $target); + } elseif ($entry->isFile()) { + Folder::create(dirname($target)); + copy($source, $target); + } + } + } + /** * Build manifest metadata for a staged upgrade. * From 43126b09e4579469265397db6d317d821665d6d6 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 14:19:16 -0600 Subject: [PATCH 04/18] fixes for 1.8 upgrades Signed-off-by: Andy Miller --- index.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/index.php b/index.php index 3ee55fab8..71ea33148 100644 --- a/index.php +++ b/index.php @@ -29,6 +29,10 @@ if (!is_file($autoload)) { // Register the auto-loader. $loader = require $autoload; +if (!class_exists(\Symfony\Component\ErrorHandler\Exception\FlattenException::class, false) && class_exists(\Symfony\Component\HttpKernel\Exception\FlattenException::class)) { + class_alias(\Symfony\Component\HttpKernel\Exception\FlattenException::class, \Symfony\Component\ErrorHandler\Exception\FlattenException::class); +} + // Set timezone to default, falls back to system if php.ini not set date_default_timezone_set(@date_default_timezone_get()); From b68872e3fd2f1911376363f2197bc2be0298a31c Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 14:32:05 -0600 Subject: [PATCH 05/18] Monolog 3 compatible shim to handle upgrades Signed-off-by: Andy Miller --- index.php | 8 + .../Grav/Framework/Compat/Monolog/Utils.php | 183 ++++++++++++++++++ .../Framework/Compat/Monolog/bootstrap.php | 28 +++ 3 files changed, 219 insertions(+) create mode 100644 system/src/Grav/Framework/Compat/Monolog/Utils.php create mode 100644 system/src/Grav/Framework/Compat/Monolog/bootstrap.php diff --git a/index.php b/index.php index 71ea33148..1a65598f7 100644 --- a/index.php +++ b/index.php @@ -33,6 +33,14 @@ if (!class_exists(\Symfony\Component\ErrorHandler\Exception\FlattenException::cl class_alias(\Symfony\Component\HttpKernel\Exception\FlattenException::class, \Symfony\Component\ErrorHandler\Exception\FlattenException::class); } +if (!class_exists(\Monolog\Logger::class, false)) { + class_exists(\Monolog\Logger::class); +} + +if (defined('Monolog\Logger::API') && \Monolog\Logger::API < 3) { + require_once __DIR__ . '/system/src/Grav/Framework/Compat/Monolog/bootstrap.php'; +} + // Set timezone to default, falls back to system if php.ini not set date_default_timezone_set(@date_default_timezone_get()); diff --git a/system/src/Grav/Framework/Compat/Monolog/Utils.php b/system/src/Grav/Framework/Compat/Monolog/Utils.php new file mode 100644 index 000000000..529ab06a7 --- /dev/null +++ b/system/src/Grav/Framework/Compat/Monolog/Utils.php @@ -0,0 +1,183 @@ + Date: Thu, 16 Oct 2025 15:24:12 -0600 Subject: [PATCH 06/18] fixes for permission retention Signed-off-by: Andy Miller --- system/src/Grav/Common/Filesystem/Folder.php | 34 +++++++++++++++++-- .../Common/Upgrade/SafeUpgradeService.php | 8 ++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/system/src/Grav/Common/Filesystem/Folder.php b/system/src/Grav/Common/Filesystem/Folder.php index 019342f9e..c01ab36bb 100644 --- a/system/src/Grav/Common/Filesystem/Folder.php +++ b/system/src/Grav/Common/Filesystem/Folder.php @@ -478,12 +478,22 @@ abstract class Folder * @return bool * @throws RuntimeException */ - public static function rcopy($src, $dest) + public static function rcopy($src, $dest, $preservePermissions = false) { // If the src is not a directory do a simple file copy if (!is_dir($src)) { copy($src, $dest); + if ($preservePermissions) { + $perm = @fileperms($src); + if ($perm !== false) { + @chmod($dest, $perm & 0777); + } + $mtime = @filemtime($src); + if ($mtime !== false) { + @touch($dest, $mtime); + } + } return true; } @@ -492,14 +502,32 @@ abstract class Folder static::create($dest); } + if ($preservePermissions) { + $perm = @fileperms($src); + if ($perm !== false) { + @chmod($dest, $perm & 0777); + } + } + // Open the source directory to read in files $i = new DirectoryIterator($src); foreach ($i as $f) { if ($f->isFile()) { - copy($f->getRealPath(), "{$dest}/" . $f->getFilename()); + $target = "{$dest}/" . $f->getFilename(); + copy($f->getRealPath(), $target); + if ($preservePermissions) { + $perm = @fileperms($f->getRealPath()); + if ($perm !== false) { + @chmod($target, $perm & 0777); + } + $mtime = @filemtime($f->getRealPath()); + if ($mtime !== false) { + @touch($target, $mtime); + } + } } else { if (!$f->isDot() && $f->isDir()) { - static::rcopy($f->getRealPath(), "{$dest}/{$f}"); + static::rcopy($f->getRealPath(), "{$dest}/{$f}", $preservePermissions); } } } diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index a7385ac29..8f9098763 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -165,7 +165,7 @@ class SafeUpgradeService Folder::create($packagePath); // Copy extracted package into staging area. - Folder::rcopy($extractedPath, $packagePath); + Folder::rcopy($extractedPath, $packagePath, true); $this->carryOverRootDotfiles($packagePath); @@ -375,7 +375,7 @@ class SafeUpgradeService } Folder::create(dirname($stage)); - Folder::rcopy($live, $stage); + Folder::rcopy($live, $stage, true); } } @@ -414,7 +414,7 @@ class SafeUpgradeService $source = $entry->getPathname(); if ($entry->isDir()) { - Folder::rcopy($source, $target); + Folder::rcopy($source, $target, true); } elseif ($entry->isFile()) { Folder::create(dirname($target)); copy($source, $target); @@ -550,7 +550,7 @@ class SafeUpgradeService Folder::delete($destinationGit); } - Folder::rcopy($sourceGit, $destinationGit); + Folder::rcopy($sourceGit, $destinationGit, true); } /** From 3f0b204728bbf9e9e5d402467712f6b70f506a58 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 17:32:43 -0600 Subject: [PATCH 07/18] Add new SafeUpgradeRun CLI command Signed-off-by: Andy Miller --- .../Console/Application/GravApplication.php | 2 + .../Console/Cli/SafeUpgradeRunCommand.php | 94 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php diff --git a/system/src/Grav/Console/Application/GravApplication.php b/system/src/Grav/Console/Application/GravApplication.php index 81e320d3b..3045e7d9c 100644 --- a/system/src/Grav/Console/Application/GravApplication.php +++ b/system/src/Grav/Console/Application/GravApplication.php @@ -19,6 +19,7 @@ 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; @@ -47,6 +48,7 @@ class GravApplication extends Application new YamlLinterCommand(), new ServerCommand(), new PageSystemValidatorCommand(), + new SafeUpgradeRunCommand(), ]); } } diff --git a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php new file mode 100644 index 000000000..7fab957c7 --- /dev/null +++ b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php @@ -0,0 +1,94 @@ +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 { + $result = $manager->run($options); + $manager->updateJob([ + 'result' => $result, + ]); + + return ($result['status'] ?? null) === 'success' ? 0 : 1; + } catch (Throwable $e) { + $manager->updateJob([ + 'status' => 'error', + 'error' => $e->getMessage(), + ]); + $manager->markJobError($e->getMessage()); + $io->error($e->getMessage()); + + return 1; + } + } +} From 09aa2fb8fd61439bdad7e9e77dd2b41c573410f6 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 21:28:08 -0600 Subject: [PATCH 08/18] ensureJobResult Signed-off-by: Andy Miller --- system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php index 7fab957c7..91a8aae11 100644 --- a/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php +++ b/system/src/Grav/Console/Cli/SafeUpgradeRunCommand.php @@ -75,17 +75,14 @@ class SafeUpgradeRunCommand extends GravCommand try { $result = $manager->run($options); - $manager->updateJob([ - 'result' => $result, - ]); + $manager->ensureJobResult($result); return ($result['status'] ?? null) === 'success' ? 0 : 1; } catch (Throwable $e) { - $manager->updateJob([ + $manager->ensureJobResult([ 'status' => 'error', - 'error' => $e->getMessage(), + 'message' => $e->getMessage(), ]); - $manager->markJobError($e->getMessage()); $io->error($e->getMessage()); return 1; From b6a37cfff3db5ba542222f1fb1b9bfb644a72735 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 23:17:34 -0600 Subject: [PATCH 09/18] preserver root files --- .../Common/Upgrade/SafeUpgradeService.php | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 8f9098763..a9eface4a 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -171,6 +171,7 @@ class SafeUpgradeService // Ensure ignored directories are replaced with live copies. $this->hydrateIgnoredDirectories($packagePath, $ignores); + $this->carryOverRootFiles($packagePath, $ignores); $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath); $manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json'; @@ -422,6 +423,57 @@ class SafeUpgradeService } } + /** + * Carry over non-dot root files that are absent from the staged package. + * + * @param string $packagePath + * @param array $ignores + * @return void + */ + private function carryOverRootFiles(string $packagePath, array $ignores): void + { + $strategic = $ignores ?: $this->ignoredDirs; + $skip = array_map(static function ($value) { + return trim((string)$value, '/'); + }, $strategic); + $skip = array_filter($skip, static function ($value) { + return $value !== ''; + }); + $skip = array_values(array_unique($skip)); + + $iterator = new DirectoryIterator($this->rootPath); + foreach ($iterator as $entry) { + if ($entry->isDot()) { + continue; + } + + $name = $entry->getFilename(); + if ($name === '' || $name[0] === '.') { + continue; + } + + if (in_array($name, $skip, true)) { + continue; + } + + $target = $packagePath . DIRECTORY_SEPARATOR . $name; + if (file_exists($target)) { + continue; + } + + $source = $entry->getPathname(); + Folder::create(dirname($target)); + + if ($entry->isDir() && !$entry->isLink()) { + Folder::rcopy($source, $target, true); + } elseif ($entry->isFile()) { + copy($source, $target); + } elseif ($entry->isLink()) { + @symlink(readlink($source), $target); + } + } + } + /** * Build manifest metadata for a staged upgrade. * From 9dd507b717b888054feb5ad53a59535fc6a4246c Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 16 Oct 2025 23:31:05 -0600 Subject: [PATCH 10/18] route safeupgrade status --- index.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/index.php b/index.php index 1a65598f7..2f75c8c03 100644 --- a/index.php +++ b/index.php @@ -20,6 +20,36 @@ if (PHP_SAPI === 'cli-server') { } } +if (PHP_SAPI !== 'cli') { + $requestUri = $_SERVER['REQUEST_URI'] ?? ''; + $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; + $path = parse_url($requestUri, PHP_URL_PATH) ?? '/'; + $path = str_replace('\\', '/', $path); + + $scriptDir = str_replace('\\', '/', dirname($scriptName)); + if ($scriptDir && $scriptDir !== '/' && $scriptDir !== '.') { + if (strpos($path, $scriptDir) === 0) { + $path = substr($path, strlen($scriptDir)); + $path = $path === '' ? '/' : $path; + } + } + + if ($path === '/___safe-upgrade-status') { + $statusEndpoint = __DIR__ . '/user/plugins/admin/safe-upgrade-status.php'; + header('Content-Type: application/json; charset=utf-8'); + if (is_file($statusEndpoint)) { + require $statusEndpoint; + } else { + http_response_code(404); + echo json_encode([ + 'status' => 'error', + 'message' => 'Safe upgrade status endpoint unavailable.', + ]); + } + exit; + } +} + // Ensure vendor libraries exist $autoload = __DIR__ . '/vendor/autoload.php'; if (!is_file($autoload)) { From 70d6aec1a7b3b42d669ac259f5a6c6c6565e4669 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 11:07:17 -0600 Subject: [PATCH 11/18] another fix for safe upgrade --- index.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/index.php b/index.php index 2f75c8c03..233701c9c 100644 --- a/index.php +++ b/index.php @@ -11,6 +11,9 @@ namespace Grav; \define('GRAV_REQUEST_TIME', microtime(true)); \define('GRAV_PHP_MIN', '7.3.6'); +if (!\defined('GRAV_ROOT')) { + \define('GRAV_ROOT', __DIR__); +} if (PHP_SAPI === 'cli-server') { $symfony_server = stripos(getenv('_'), 'symfony') !== false || stripos($_SERVER['SERVER_SOFTWARE'] ?? '', 'symfony') !== false || stripos($_ENV['SERVER_SOFTWARE'] ?? '', 'symfony') !== false; From 286b5a5179c6a4d3099604059a15dec0f22e7a41 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 11:26:43 -0600 Subject: [PATCH 12/18] fix for binary permissions in CLI --- .../Grav/Console/Gpm/SelfupgradeCommand.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index ea42f4b3d..109028247 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -457,6 +457,8 @@ class SelfupgradeCommand extends GpmCommand // extra white spaces to clear out the buffer properly $io->writeln(' |- Installing upgrade... ok '); + $this->ensureExecutablePermissions(); + return true; } @@ -512,4 +514,28 @@ class SelfupgradeCommand extends GpmCommand Installer::setError($e->getMessage()); } } + + private function ensureExecutablePermissions(): void + { + $executables = [ + 'bin/grav', + 'bin/plugin', + 'bin/gpm', + 'bin/restore', + 'bin/composer.phar' + ]; + + foreach ($executables as $relative) { + $path = GRAV_ROOT . '/' . $relative; + if (!is_file($path) || is_link($path)) { + continue; + } + + $mode = @fileperms($path); + $desired = ($mode & 0777) | 0111; + if (($mode & 0111) !== 0111) { + @chmod($path, $desired); + } + } + } } From 9230a5a40f6217922f3ffbac37f94fee351e1de2 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 11:32:38 -0600 Subject: [PATCH 13/18] ingore recovery window Signed-off-by: Andy Miller --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a2a78f5f2..87b0eb18c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ tests/cache/* tests/error.log system/templates/testing/* /user/config/versions.yaml +/system/recovery.window From 5e7b482972899e774ba350e9c6b07716f11cc8c6 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 11:34:35 -0600 Subject: [PATCH 14/18] test fix Signed-off-by: Andy Miller --- .../src/Grav/Common/Upgrade/SafeUpgradeService.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index a9eface4a..c334c5567 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -456,6 +456,10 @@ class SafeUpgradeService continue; } + if (!$entry->isDir() || $entry->isLink()) { + continue; + } + $target = $packagePath . DIRECTORY_SEPARATOR . $name; if (file_exists($target)) { continue; @@ -464,13 +468,7 @@ class SafeUpgradeService $source = $entry->getPathname(); Folder::create(dirname($target)); - if ($entry->isDir() && !$entry->isLink()) { - Folder::rcopy($source, $target, true); - } elseif ($entry->isFile()) { - copy($source, $target); - } elseif ($entry->isLink()) { - @symlink(readlink($source), $target); - } + Folder::rcopy($source, $target, true); } } From d97b2d70bda7ce44f607a61e350cc7e52cfd9abb Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 16:18:40 -0600 Subject: [PATCH 15/18] logic fixes --- .../Common/Upgrade/SafeUpgradeService.php | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index c334c5567..fe781880b 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -60,6 +60,8 @@ class SafeUpgradeService private $stagingRoot; /** @var string */ private $manifestStore; + /** @var \Grav\Common\Config\ConfigInterface|null */ + private $config; /** @var array */ private $ignoredDirs = [ @@ -79,6 +81,7 @@ class SafeUpgradeService $root = $options['root'] ?? GRAV_ROOT; $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); + $this->config = $options['config'] ?? null; $candidates = []; if (!empty($options['staging_root'])) { @@ -278,6 +281,9 @@ class SafeUpgradeService } $slug = basename($path); + if (!$this->isPluginEnabled($slug)) { + continue; + } $rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null); if (!$rawConstraint) { continue; @@ -302,6 +308,34 @@ class SafeUpgradeService return $conflicts; } + protected function isPluginEnabled(string $slug): bool + { + if ($this->config) { + try { + $value = $this->config->get("plugins.{$slug}.enabled"); + if ($value !== null) { + return (bool)$value; + } + } catch (Throwable $e) { + // ignore and fall back to file checks + } + } + + $configPath = $this->rootPath . '/user/config/plugins/' . $slug . '.yaml'; + if (is_file($configPath)) { + try { + $data = Yaml::parseFile($configPath); + if (is_array($data) && array_key_exists('enabled', $data)) { + return (bool)$data['enabled']; + } + } catch (Throwable $e) { + // ignore parse errors and treat as enabled + } + } + + return true; + } + /** * Detect usage of deprecated Monolog `add*` methods removed in newer releases. * @@ -314,6 +348,11 @@ class SafeUpgradeService $pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i'; foreach ($pluginRoots as $path) { + $slug = basename($path); + if (!$this->isPluginEnabled($slug)) { + continue; + } + $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS) ); @@ -330,7 +369,6 @@ class SafeUpgradeService } if (preg_match($pattern, $contents, $match)) { - $slug = basename($path); $relative = str_replace($this->rootPath . '/', '', $file->getPathname()); $conflicts[$slug][] = [ 'file' => $relative, From 2999c06a3a6a88061dc3ac8e02acbc8a8cc849b7 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 16:49:42 -0600 Subject: [PATCH 16/18] change snapshot storage --- system/blueprints/config/system.yaml | 9 ----- system/config/system.yaml | 1 - system/languages/en.yaml | 2 -- .../Common/Upgrade/SafeUpgradeService.php | 36 +++++++++++-------- .../Grav/Console/Gpm/SelfupgradeCommand.php | 4 +-- system/src/Grav/Installer/Install.php | 2 +- .../Common/Upgrade/SafeUpgradeServiceTest.php | 7 +--- 7 files changed, 24 insertions(+), 37 deletions(-) diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index 67c77362d..c90bb2d5f 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -1614,14 +1614,6 @@ form: 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 @@ -1936,4 +1928,3 @@ form: # # pages.type: # type: hidden - diff --git a/system/config/system.yaml b/system/config/system.yaml index 50cbcd28f..87b6d4c1c 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -205,7 +205,6 @@ gpm: 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 diff --git a/system/languages/en.yaml b/system/languages/en.yaml index 973199ff3..3b34a93db 100644 --- a/system/languages/en.yaml +++ b/system/languages/en.yaml @@ -124,5 +124,3 @@ 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/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index fe781880b..fe43a5147 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -12,6 +12,7 @@ namespace Grav\Common\Upgrade; use DirectoryIterator; use Grav\Common\Filesystem\Folder; use Grav\Common\GPM\GPM; +use Grav\Common\Grav; use Grav\Common\Yaml; use InvalidArgumentException; use RuntimeException; @@ -83,27 +84,30 @@ class SafeUpgradeService $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); $this->config = $options['config'] ?? null; - $candidates = []; - if (!empty($options['staging_root'])) { - $candidates[] = $options['staging_root']; + $locator = null; + try { + $locator = Grav::instance()['locator'] ?? null; + } catch (Throwable $e) { + $locator = null; } - $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; + $primary = null; + if ($locator && method_exists($locator, 'findResource')) { + try { + $primary = $locator->findResource('tmp://grav-upgrades', true, true); + } catch (Throwable $e) { + $primary = null; } } + if (!$primary) { + $primary = $this->rootPath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . 'grav-upgrades'; + } + + $this->stagingRoot = $this->resolveStagingPath($primary); + if (null === $this->stagingRoot) { - throw new RuntimeException('Unable to locate writable staging directory. Configure system.updates.staging_root or adjust permissions.'); + throw new RuntimeException('Unable to locate writable staging directory. Ensure tmp://grav-upgrades is writable.'); } $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'])) { @@ -671,6 +675,8 @@ class SafeUpgradeService $home = getenv('HOME'); if ($home) { $expanded = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($expanded, '~\/'); + } else { + return null; } } if (!$this->isAbsolutePath($expanded)) { diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index 109028247..e98fb15da 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -292,10 +292,8 @@ class SelfupgradeCommand extends GpmCommand $config = null; } - $stagingRoot = $config ? $config->get('system.updates.staging_root') : null; - return new SafeUpgradeService([ - 'staging_root' => $stagingRoot, + 'config' => $config, ]); } diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 794e6f67e..e9c59b6e9 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -266,7 +266,7 @@ ERR; try { $grav = Grav::instance(); if ($grav && isset($grav['config'])) { - $options['staging_root'] = $grav['config']->get('system.updates.staging_root'); + $options['config'] = $grav['config']; } } catch (\Throwable $e) { // ignore diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index b69bef31e..e46cbb65a 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -94,7 +94,6 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -121,7 +120,6 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -147,7 +145,6 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); @@ -176,7 +173,6 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts'); @@ -197,7 +193,6 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $service->clearRecoveryFlag(); @@ -210,7 +205,7 @@ PHP; private function prepareLiveEnvironment(): array { $root = $this->tmpDir . '/root'; - $staging = $this->tmpDir . '/staging'; + $staging = $root . '/tmp/grav-upgrades'; $manifestStore = $root . '/user/data/upgrades'; Folder::create($root . '/user/plugins/sample'); From 920642411ca7b9b2492f6f2e84499629559f707a Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 17:53:48 -0600 Subject: [PATCH 17/18] move back to cp instead of mv for snapshots Signed-off-by: Andy Miller --- .gitignore | 1 + bin/restore | 43 ----- system/UPGRADE_PROTOTYPE.md | 7 +- .../Common/Upgrade/SafeUpgradeService.php | 176 ++++++++++-------- .../Common/Upgrade/SafeUpgradeServiceTest.php | 12 +- 5 files changed, 115 insertions(+), 124 deletions(-) diff --git a/.gitignore b/.gitignore index 87b0eb18c..7b556f1cb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ tests/error.log system/templates/testing/* /user/config/versions.yaml /system/recovery.window +tmp/* diff --git a/bin/restore b/bin/restore index 7de81cfcf..b63d2d88d 100755 --- a/bin/restore +++ b/bin/restore @@ -66,11 +66,6 @@ function parseArguments(array $args): array $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); @@ -89,50 +84,12 @@ function parseArguments(array $args): array /** * @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); diff --git a/system/UPGRADE_PROTOTYPE.md b/system/UPGRADE_PROTOTYPE.md index 2047ee284..6444a4f26 100644 --- a/system/UPGRADE_PROTOTYPE.md +++ b/system/UPGRADE_PROTOTYPE.md @@ -16,13 +16,13 @@ This document tracks the design decisions behind the new self-upgrade prototype - 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}`). + - Download the Grav update archive into a staging area (`tmp://grav-snapshots/{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. + - Copy the staged package into place, overwriting Grav core files while leaving hydrated user content intact. - 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. + - Run Grav CLI smoke checks (`bin/grav check`) while still holding maintenance state; restore from the snapshot automatically on failure. 4. **Finalize** - Record the manifest under `user/data/upgrades`. - Resume normal traffic by removing the maintenance flag. @@ -46,4 +46,3 @@ This document tracks the design decisions behind the new self-upgrade prototype - 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/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index fe43a5147..a14ddca02 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -56,8 +56,6 @@ class SafeUpgradeService /** @var string */ private $rootPath; /** @var string */ - private $parentDir; - /** @var string */ private $stagingRoot; /** @var string */ private $manifestStore; @@ -81,7 +79,6 @@ class SafeUpgradeService { $root = $options['root'] ?? GRAV_ROOT; $this->rootPath = rtrim($root, DIRECTORY_SEPARATOR); - $this->parentDir = $options['parent_dir'] ?? dirname($this->rootPath); $this->config = $options['config'] ?? null; $locator = null; @@ -94,20 +91,20 @@ class SafeUpgradeService $primary = null; if ($locator && method_exists($locator, 'findResource')) { try { - $primary = $locator->findResource('tmp://grav-upgrades', true, true); + $primary = $locator->findResource('tmp://grav-snapshots', true, true); } catch (Throwable $e) { $primary = null; } } if (!$primary) { - $primary = $this->rootPath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . 'grav-upgrades'; + $primary = $this->rootPath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR . 'grav-snapshots'; } $this->stagingRoot = $this->resolveStagingPath($primary); if (null === $this->stagingRoot) { - throw new RuntimeException('Unable to locate writable staging directory. Ensure tmp://grav-upgrades is writable.'); + throw new RuntimeException('Unable to locate writable staging directory. Ensure tmp://grav-snapshots is writable.'); } $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'])) { @@ -167,7 +164,7 @@ class SafeUpgradeService $stageId = uniqid('stage-', false); $stagePath = $this->stagingRoot . DIRECTORY_SEPARATOR . $stageId; $packagePath = $stagePath . DIRECTORY_SEPARATOR . 'package'; - $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'rollback-' . $stageId; + $backupPath = $this->stagingRoot . DIRECTORY_SEPARATOR . 'snapshot-' . $stageId; Folder::create($packagePath); @@ -180,13 +177,28 @@ class SafeUpgradeService $this->hydrateIgnoredDirectories($packagePath, $ignores); $this->carryOverRootFiles($packagePath, $ignores); - $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath); + $entries = $this->collectPackageEntries($packagePath); + if (!$entries) { + throw new RuntimeException('Staged package does not contain any files to promote.'); + } + + $this->createBackupSnapshot($entries, $backupPath); + $this->syncGitDirectory($this->rootPath, $backupPath); + + $manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $entries); $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); + try { + $this->copyEntries($entries, $packagePath, $this->rootPath); + } catch (Throwable $e) { + $this->copyEntries($entries, $backupPath, $this->rootPath); + $this->syncGitDirectory($backupPath, $this->rootPath); + throw new RuntimeException('Failed to promote staged Grav release.', 0, $e); + } + + $this->syncGitDirectory($backupPath, $this->rootPath); $this->persistManifest($manifest); $this->pruneOldSnapshots(); Folder::delete($stagePath); @@ -194,6 +206,74 @@ class SafeUpgradeService return $manifest; } + private function collectPackageEntries(string $packagePath): array + { + $entries = []; + $iterator = new DirectoryIterator($packagePath); + foreach ($iterator as $fileinfo) { + if ($fileinfo->isDot()) { + continue; + } + + $entries[] = $fileinfo->getFilename(); + } + + sort($entries); + + return $entries; + } + + private function createBackupSnapshot(array $entries, string $backupPath): void + { + Folder::create($backupPath); + $this->copyEntries($entries, $this->rootPath, $backupPath); + } + + private function copyEntries(array $entries, string $sourceBase, string $targetBase): void + { + foreach ($entries as $entry) { + $source = $sourceBase . DIRECTORY_SEPARATOR . $entry; + if (!is_file($source) && !is_dir($source) && !is_link($source)) { + continue; + } + + $destination = $targetBase . DIRECTORY_SEPARATOR . $entry; + $this->removeEntry($destination); + + if (is_link($source)) { + Folder::create(dirname($destination)); + if (!@symlink(readlink($source), $destination)) { + throw new RuntimeException(sprintf('Failed to replicate symlink "%s".', $source)); + } + } elseif (is_dir($source)) { + Folder::create(dirname($destination)); + Folder::rcopy($source, $destination, true); + } else { + Folder::create(dirname($destination)); + if (!@copy($source, $destination)) { + throw new RuntimeException(sprintf('Failed to copy file "%s" to "%s".', $source, $destination)); + } + $perm = @fileperms($source); + if ($perm !== false) { + @chmod($destination, $perm & 0777); + } + $mtime = @filemtime($source); + if ($mtime !== false) { + @touch($destination, $mtime); + } + } + } + } + + private function removeEntry(string $path): void + { + if (is_link($path) || is_file($path)) { + @unlink($path); + } elseif (is_dir($path)) { + Folder::delete($path); + } + } + /** * Roll back to the most recent snapshot. * @@ -212,15 +292,17 @@ class SafeUpgradeService 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); + $entries = $manifest['entries'] ?? []; + if (!$entries) { + $entries = $this->collectPackageEntries($backupPath); } + if (!$entries) { + throw new RuntimeException('Rollback snapshot entries are missing from the manifest.'); + } + + $this->copyEntries($entries, $backupPath, $this->rootPath); + $this->syncGitDirectory($backupPath, $this->rootPath); + $this->markRollback($manifest['id']); return $manifest; } @@ -523,7 +605,7 @@ class SafeUpgradeService * @param string $backupPath * @return array */ - private function buildManifest(string $stageId, string $targetVersion, string $packagePath, string $backupPath): array + private function buildManifest(string $stageId, string $targetVersion, string $packagePath, string $backupPath, array $entries): array { $plugins = []; $pluginRoots = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; @@ -560,65 +642,11 @@ class SafeUpgradeService 'php_version' => PHP_VERSION, 'package_path' => $packagePath, 'backup_path' => $backupPath, + 'entries' => array_values($entries), '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. * diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index e46cbb65a..368a001ff 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -94,6 +94,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -111,8 +112,9 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test self::assertFileExists($root . '/ORIGINAL'); self::assertFileDoesNotExist($root . '/system/new.txt'); - $rotated = glob($staging . '/rotated-*'); - self::assertEmpty($rotated); + $snapshots = glob($staging . '/snapshot-*'); + self::assertNotEmpty($snapshots); + self::assertEmpty(glob($staging . '/stage-*')); } public function testPrunesOldSnapshots(): void @@ -120,6 +122,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -145,6 +148,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); @@ -173,6 +177,7 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts'); @@ -193,6 +198,7 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, + 'staging_root' => $this->tmpDir . '/staging', ]); $service->clearRecoveryFlag(); @@ -205,7 +211,7 @@ PHP; private function prepareLiveEnvironment(): array { $root = $this->tmpDir . '/root'; - $staging = $root . '/tmp/grav-upgrades'; + $staging = $this->tmpDir . '/staging'; $manifestStore = $root . '/user/data/upgrades'; Folder::create($root . '/user/plugins/sample'); From 44fd1172b815a5df031fbceb4ef5a14811465226 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 17 Oct 2025 18:18:53 -0600 Subject: [PATCH 18/18] more granular install for self upgrade Signed-off-by: Andy Miller --- .../Common/Upgrade/SafeUpgradeService.php | 22 +++++++++++++++++ .../Grav/Console/Gpm/SelfupgradeCommand.php | 24 +++++++++++++++++++ system/src/Grav/Installer/Install.php | 21 ++++++++++++++++ .../Common/Upgrade/SafeUpgradeServiceTest.php | 20 +++++----------- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index a14ddca02..7a43f4c20 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -71,6 +71,8 @@ class SafeUpgradeService 'cache', 'user', ]; + /** @var callable|null */ + private $progressCallback = null; /** * @param array $options @@ -170,6 +172,7 @@ class SafeUpgradeService // Copy extracted package into staging area. Folder::rcopy($extractedPath, $packagePath, true); + $this->reportProgress('installing', 'Preparing staged package...', null); $this->carryOverRootDotfiles($packagePath); @@ -182,6 +185,7 @@ class SafeUpgradeService throw new RuntimeException('Staged package does not contain any files to promote.'); } + $this->reportProgress('snapshot', 'Creating backup snapshot...', null); $this->createBackupSnapshot($entries, $backupPath); $this->syncGitDirectory($this->rootPath, $backupPath); @@ -190,6 +194,8 @@ class SafeUpgradeService Folder::create(dirname($manifestPath)); file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT)); + $this->reportProgress('installing', 'Copying update files...', null); + try { $this->copyEntries($entries, $packagePath, $this->rootPath); } catch (Throwable $e) { @@ -198,6 +204,7 @@ class SafeUpgradeService throw new RuntimeException('Failed to promote staged Grav release.', 0, $e); } + $this->reportProgress('finalizing', 'Finalizing upgrade...', null); $this->syncGitDirectory($backupPath, $this->rootPath); $this->persistManifest($manifest); $this->pruneOldSnapshots(); @@ -274,6 +281,20 @@ class SafeUpgradeService } } + public function setProgressCallback(?callable $callback): self + { + $this->progressCallback = $callback; + + return $this; + } + + private function reportProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void + { + if ($this->progressCallback) { + ($this->progressCallback)($stage, $message, $percent, $extra); + } + } + /** * Roll back to the most recent snapshot. * @@ -300,6 +321,7 @@ class SafeUpgradeService throw new RuntimeException('Rollback snapshot entries are missing from the manifest.'); } + $this->reportProgress('rollback', 'Restoring snapshot...', null); $this->copyEntries($entries, $backupPath, $this->rootPath); $this->syncGitDirectory($backupPath, $this->rootPath); $this->markRollback($manifest['id']); diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index e98fb15da..f34c1870e 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -44,6 +44,8 @@ class SelfupgradeCommand extends GpmCommand private $tmp; /** @var Upgrader */ private $upgrader; + /** @var string|null */ + private $lastProgressMessage = null; /** @var string */ protected $all_yes; @@ -438,6 +440,7 @@ class SelfupgradeCommand extends GpmCommand private function upgrade(): bool { $io = $this->getIO(); + $this->lastProgressMessage = null; $this->upgradeGrav($this->file); @@ -496,14 +499,24 @@ class SelfupgradeCommand extends GpmCommand */ private function upgradeGrav(string $zip): void { + $io = $this->getIO(); + try { + $io->write("\x0D |- Extracting update... "); $folder = Installer::unZip($zip, $this->tmp . '/zip'); if ($folder === false) { throw new RuntimeException(Installer::lastErrorMsg()); } + $io->write("\x0D"); + $io->writeln(' |- Extracting update... ok '); $script = $folder . '/system/install.php'; if ((file_exists($script) && $install = include $script) && is_callable($install)) { + if (is_object($install) && method_exists($install, 'setProgressCallback')) { + $install->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) { + $this->handleServiceProgress($stage, $message, $percent); + }); + } $install($zip); } else { throw new RuntimeException('Uploaded archive file is not a valid Grav update package'); @@ -513,6 +526,17 @@ class SelfupgradeCommand extends GpmCommand } } + private function handleServiceProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void + { + if ($this->lastProgressMessage === $message) { + return; + } + + $this->lastProgressMessage = $message; + $io = $this->getIO(); + $io->writeln(sprintf(' |- %s', $message)); + } + private function ensureExecutablePermissions(): void { $executables = [ diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index e9c59b6e9..9de229c02 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -122,6 +122,8 @@ final class Install /** @var static */ private static $instance; + /** @var callable|null */ + private $progressCallback = null; /** * @return static @@ -187,6 +189,20 @@ ERR; $this->finalize(); } + public function setProgressCallback(?callable $callback): self + { + $this->progressCallback = $callback; + + return $this; + } + + private function relayProgress(string $stage, string $message, ?int $percent = null): void + { + if ($this->progressCallback) { + ($this->progressCallback)($stage, $message, $percent); + } + } + /** * NOTE: This method can only be called after $grav['plugins']->init(). * @@ -273,6 +289,11 @@ ERR; } $service = new SafeUpgradeService($options); + if ($this->progressCallback) { + $service->setProgressCallback(function (string $stage, string $message, ?int $percent = null, array $extra = []) { + $this->relayProgress($stage, $message, $percent); + }); + } $service->promote($this->location, $this->getVersion(), $this->ignores); Installer::setError(Installer::OK); } else { diff --git a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php index 368a001ff..4873770c3 100644 --- a/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php +++ b/tests/unit/Grav/Common/Upgrade/SafeUpgradeServiceTest.php @@ -91,10 +91,9 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test public function testPromoteAndRollback(): void { - [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); + [$root, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -102,7 +101,7 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test $manifest = $service->promote($package, '1.8.0', ['backup', 'cache', 'images', 'logs', 'tmp', 'user']); self::assertFileExists($root . '/system/new.txt'); - self::assertFileDoesNotExist($root . '/ORIGINAL'); + self::assertFileExists($root . '/ORIGINAL'); $manifestFile = $manifestStore . '/' . $manifest['id'] . '.json'; self::assertFileExists($manifestFile); @@ -112,17 +111,14 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test self::assertFileExists($root . '/ORIGINAL'); self::assertFileDoesNotExist($root . '/system/new.txt'); - $snapshots = glob($staging . '/snapshot-*'); - self::assertNotEmpty($snapshots); - self::assertEmpty(glob($staging . '/stage-*')); + self::assertDirectoryExists($manifest['backup_path']); } public function testPrunesOldSnapshots(): void { - [$root, $staging, $manifestStore] = $this->prepareLiveEnvironment(); + [$root, $manifestStore] = $this->prepareLiveEnvironment(); $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $staging, 'manifest_store' => $manifestStore, ]); @@ -148,7 +144,6 @@ class SafeUpgradeServiceTest extends \Codeception\TestCase\Test $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts'); @@ -177,7 +172,6 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts'); @@ -198,7 +192,6 @@ PHP; $service = new SafeUpgradeService([ 'root' => $root, - 'staging_root' => $this->tmpDir . '/staging', ]); $service->clearRecoveryFlag(); @@ -206,12 +199,11 @@ PHP; } /** - * @return array{0:string,1:string,2:string} + * @return array{0:string,1:string} */ private function prepareLiveEnvironment(): array { $root = $this->tmpDir . '/root'; - $staging = $this->tmpDir . '/staging'; $manifestStore = $root . '/user/data/upgrades'; Folder::create($root . '/user/plugins/sample'); @@ -221,7 +213,7 @@ PHP; 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]; + return [$root, $manifestStore]; } /**