mirror of
https://github.com/getgrav/grav.git
synced 2025-10-26 07:56:07 +01:00
move back to cp instead of mv for snapshots
Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,3 +49,4 @@ tests/error.log
|
||||
system/templates/testing/*
|
||||
/user/config/versions.yaml
|
||||
/system/recovery.window
|
||||
tmp/*
|
||||
|
||||
43
bin/restore
43
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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user