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');