move back to cp instead of mv for snapshots

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2025-10-17 17:53:48 -06:00
parent 2999c06a3a
commit 920642411c
5 changed files with 115 additions and 124 deletions

1
.gitignore vendored
View File

@@ -49,3 +49,4 @@ tests/error.log
system/templates/testing/*
/user/config/versions.yaml
/system/recovery.window
tmp/*

View File

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

View File

@@ -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).

View File

@@ -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.
*

View File

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