mirror of
https://github.com/getgrav/grav.git
synced 2025-10-26 07:56:07 +01:00
Merge branch 'develop' into 1.8
Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
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).
|
||||
|
||||
|
||||
@@ -1596,14 +1596,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
|
||||
@@ -1930,4 +1922,3 @@ form:
|
||||
#
|
||||
# pages.type:
|
||||
# type: hidden
|
||||
|
||||
|
||||
@@ -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 parent>/grav-upgrades)
|
||||
|
||||
http:
|
||||
method: auto # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -55,11 +56,11 @@ class SafeUpgradeService
|
||||
/** @var string */
|
||||
private $rootPath;
|
||||
/** @var string */
|
||||
private $parentDir;
|
||||
/** @var string */
|
||||
private $stagingRoot;
|
||||
/** @var string */
|
||||
private $manifestStore;
|
||||
/** @var \Grav\Common\Config\ConfigInterface|null */
|
||||
private $config;
|
||||
|
||||
/** @var array */
|
||||
private $ignoredDirs = [
|
||||
@@ -70,6 +71,8 @@ class SafeUpgradeService
|
||||
'cache',
|
||||
'user',
|
||||
];
|
||||
/** @var callable|null */
|
||||
private $progressCallback = null;
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
@@ -78,29 +81,32 @@ 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'])) {
|
||||
$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-snapshots', true, true);
|
||||
} catch (Throwable $e) {
|
||||
$primary = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$primary) {
|
||||
$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. Configure system.updates.staging_root or adjust permissions.');
|
||||
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'])) {
|
||||
@@ -160,12 +166,13 @@ 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);
|
||||
|
||||
// Copy extracted package into staging area.
|
||||
Folder::rcopy($extractedPath, $packagePath, true);
|
||||
$this->reportProgress('installing', 'Preparing staged package...', null);
|
||||
|
||||
$this->carryOverRootDotfiles($packagePath);
|
||||
|
||||
@@ -173,13 +180,32 @@ 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->reportProgress('snapshot', 'Creating backup snapshot...', null);
|
||||
$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);
|
||||
$this->reportProgress('installing', 'Copying update files...', null);
|
||||
|
||||
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->reportProgress('finalizing', 'Finalizing upgrade...', null);
|
||||
$this->syncGitDirectory($backupPath, $this->rootPath);
|
||||
$this->persistManifest($manifest);
|
||||
$this->pruneOldSnapshots();
|
||||
Folder::delete($stagePath);
|
||||
@@ -187,6 +213,88 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -205,15 +313,18 @@ 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->reportProgress('rollback', 'Restoring snapshot...', null);
|
||||
$this->copyEntries($entries, $backupPath, $this->rootPath);
|
||||
$this->syncGitDirectory($backupPath, $this->rootPath);
|
||||
$this->markRollback($manifest['id']);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
@@ -278,6 +389,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 +416,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 +456,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 +477,6 @@ class SafeUpgradeService
|
||||
}
|
||||
|
||||
if (preg_match($pattern, $contents, $match)) {
|
||||
$slug = basename($path);
|
||||
$relative = str_replace($this->rootPath . '/', '', $file->getPathname());
|
||||
$conflicts[$slug][] = [
|
||||
'file' => $relative,
|
||||
@@ -481,7 +627,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) ?: [];
|
||||
@@ -518,65 +664,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.
|
||||
*
|
||||
@@ -633,6 +725,8 @@ class SafeUpgradeService
|
||||
$home = getenv('HOME');
|
||||
if ($home) {
|
||||
$expanded = rtrim($home, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($expanded, '~\/');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!$this->isAbsolutePath($expanded)) {
|
||||
|
||||
@@ -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;
|
||||
@@ -290,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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -438,6 +438,7 @@ class SelfupgradeCommand extends GpmCommand
|
||||
private function upgrade(): bool
|
||||
{
|
||||
$io = $this->getIO();
|
||||
$this->lastProgressMessage = null;
|
||||
|
||||
$this->upgradeGrav($this->file);
|
||||
|
||||
@@ -496,14 +497,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... <green>ok</green> ');
|
||||
|
||||
$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 +524,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 = [
|
||||
|
||||
183
system/src/Grav/Framework/Compat/Monolog/Utils.php
Normal file
183
system/src/Grav/Framework/Compat/Monolog/Utils.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Backport of Monolog\Utils providing DEFAULT_JSON_FLAGS for older Monolog versions.
|
||||
*
|
||||
* This is a trimmed copy of the Monolog 1.x Utils class with a compatible constant so
|
||||
* that Grav 1.7 can interoperate with code targeting Monolog 3.
|
||||
*/
|
||||
|
||||
namespace Monolog;
|
||||
|
||||
class Utils
|
||||
{
|
||||
public const DEFAULT_JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function getClass($object)
|
||||
{
|
||||
$class = \get_class($object);
|
||||
|
||||
return 'c' === $class[0] && 0 === strpos($class, "class@anonymous\0") ? get_parent_class($class).'@anonymous' : $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure if a relative path is passed in it is turned into an absolute path
|
||||
*
|
||||
* @param string $streamUrl stream URL or path without protocol
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function canonicalizePath($streamUrl)
|
||||
{
|
||||
$prefix = '';
|
||||
if ('file://' === substr($streamUrl, 0, 7)) {
|
||||
$streamUrl = substr($streamUrl, 7);
|
||||
$prefix = 'file://';
|
||||
}
|
||||
|
||||
if (false !== strpos($streamUrl, '://')) {
|
||||
return $streamUrl;
|
||||
}
|
||||
|
||||
if (substr($streamUrl, 0, 1) === '/' || substr($streamUrl, 1, 1) === ':' || substr($streamUrl, 0, 2) === '\\\\') {
|
||||
return $prefix.$streamUrl;
|
||||
}
|
||||
|
||||
$streamUrl = getcwd() . '/' . $streamUrl;
|
||||
|
||||
return $prefix.$streamUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the JSON representation of a value
|
||||
*
|
||||
* @param mixed $data
|
||||
* @param int $encodeFlags
|
||||
* @param bool $ignoreErrors
|
||||
* @return string
|
||||
*/
|
||||
public static function jsonEncode($data, $encodeFlags = null, $ignoreErrors = false)
|
||||
{
|
||||
if (null === $encodeFlags) {
|
||||
$encodeFlags = self::DEFAULT_JSON_FLAGS;
|
||||
if (defined('JSON_PRESERVE_ZERO_FRACTION')) {
|
||||
$encodeFlags |= JSON_PRESERVE_ZERO_FRACTION;
|
||||
}
|
||||
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
|
||||
$encodeFlags |= JSON_INVALID_UTF8_SUBSTITUTE;
|
||||
}
|
||||
if (defined('JSON_PARTIAL_OUTPUT_ON_ERROR')) {
|
||||
$encodeFlags |= JSON_PARTIAL_OUTPUT_ON_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ignoreErrors) {
|
||||
$json = @json_encode($data, $encodeFlags);
|
||||
if (false === $json) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
$json = json_encode($data, $encodeFlags);
|
||||
if (false === $json) {
|
||||
$json = self::handleJsonError(json_last_error(), $data);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a json_encode failure.
|
||||
*
|
||||
* @param int $code
|
||||
* @param mixed $data
|
||||
* @param int $encodeFlags
|
||||
* @return string
|
||||
*/
|
||||
public static function handleJsonError($code, $data, $encodeFlags = null)
|
||||
{
|
||||
if ($code !== JSON_ERROR_UTF8) {
|
||||
self::throwEncodeError($code, $data);
|
||||
}
|
||||
|
||||
if (is_string($data)) {
|
||||
self::detectAndCleanUtf8($data);
|
||||
} elseif (is_array($data)) {
|
||||
array_walk_recursive($data, array('Monolog\Utils', 'detectAndCleanUtf8'));
|
||||
} else {
|
||||
self::throwEncodeError($code, $data);
|
||||
}
|
||||
|
||||
if (null === $encodeFlags) {
|
||||
$encodeFlags = self::DEFAULT_JSON_FLAGS;
|
||||
if (defined('JSON_PRESERVE_ZERO_FRACTION')) {
|
||||
$encodeFlags |= JSON_PRESERVE_ZERO_FRACTION;
|
||||
}
|
||||
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
|
||||
$encodeFlags |= JSON_INVALID_UTF8_SUBSTITUTE;
|
||||
}
|
||||
if (defined('JSON_PARTIAL_OUTPUT_ON_ERROR')) {
|
||||
$encodeFlags |= JSON_PARTIAL_OUTPUT_ON_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_encode($data, $encodeFlags);
|
||||
|
||||
if ($json === false) {
|
||||
self::throwEncodeError(json_last_error(), $data);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $code
|
||||
* @param mixed $data
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
private static function throwEncodeError($code, $data)
|
||||
{
|
||||
switch ($code) {
|
||||
case JSON_ERROR_DEPTH:
|
||||
$msg = 'Maximum stack depth exceeded';
|
||||
break;
|
||||
case JSON_ERROR_STATE_MISMATCH:
|
||||
$msg = 'Underflow or the modes mismatch';
|
||||
break;
|
||||
case JSON_ERROR_CTRL_CHAR:
|
||||
$msg = 'Unexpected control character found';
|
||||
break;
|
||||
case JSON_ERROR_UTF8:
|
||||
$msg = 'Malformed UTF-8 characters, possibly incorrectly encoded';
|
||||
break;
|
||||
default:
|
||||
$msg = 'Unknown error';
|
||||
}
|
||||
|
||||
throw new \RuntimeException('JSON encoding failed: '.$msg.'. Encoding: '.var_export($data, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
public static function detectAndCleanUtf8(&$data)
|
||||
{
|
||||
if (is_string($data) && !preg_match('//u', $data)) {
|
||||
$data = preg_replace_callback(
|
||||
'/[\x80-\xFF]+/',
|
||||
function ($m) { return utf8_encode($m[0]); },
|
||||
$data
|
||||
);
|
||||
$data = str_replace(
|
||||
array('¤', '¦', '¨', '´', '¸', '¼', '½', '¾'),
|
||||
array('€', 'Š', 'š', 'Ž', 'ž', 'Œ', 'œ', 'Ÿ'),
|
||||
$data
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
system/src/Grav/Framework/Compat/Monolog/bootstrap.php
Normal file
28
system/src/Grav/Framework/Compat/Monolog/bootstrap.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Framework\Compat
|
||||
*
|
||||
* Provides lightweight shims for legacy Monolog installations used in Grav 1.7
|
||||
* so that newer Grav code (targeting Monolog 3) can run without fatal errors.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Grav\Framework\Compat\Monolog;
|
||||
|
||||
if (!class_exists(\Monolog\Utils::class, false)) {
|
||||
spl_autoload_register(
|
||||
static function (string $class): bool {
|
||||
if ($class === 'Monolog\\Utils') {
|
||||
require __DIR__ . '/Utils.php';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
@@ -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().
|
||||
*
|
||||
@@ -266,13 +282,18 @@ 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
|
||||
}
|
||||
|
||||
$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 {
|
||||
|
||||
@@ -95,10 +95,9 @@ class SafeUpgradeServiceTest extends \PHPUnit\Framework\TestCase
|
||||
|
||||
public function testPromoteAndRollback(): void
|
||||
{
|
||||
[$root, $staging, $manifestStore] = $this->prepareLiveEnvironment();
|
||||
[$root, $manifestStore] = $this->prepareLiveEnvironment();
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'staging_root' => $staging,
|
||||
'manifest_store' => $manifestStore,
|
||||
]);
|
||||
|
||||
@@ -106,7 +105,7 @@ class SafeUpgradeServiceTest extends \PHPUnit\Framework\TestCase
|
||||
$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);
|
||||
@@ -116,16 +115,14 @@ class SafeUpgradeServiceTest extends \PHPUnit\Framework\TestCase
|
||||
self::assertFileExists($root . '/ORIGINAL');
|
||||
self::assertFileDoesNotExist($root . '/system/new.txt');
|
||||
|
||||
$rotated = glob($staging . '/rotated-*');
|
||||
self::assertEmpty($rotated);
|
||||
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,
|
||||
]);
|
||||
|
||||
@@ -151,7 +148,6 @@ class SafeUpgradeServiceTest extends \PHPUnit\Framework\TestCase
|
||||
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'staging_root' => $this->tmpDir . '/staging',
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod(SafeUpgradeService::class, 'detectPsrLogConflicts');
|
||||
@@ -180,7 +176,6 @@ PHP;
|
||||
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'staging_root' => $this->tmpDir . '/staging',
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod(SafeUpgradeService::class, 'detectMonologConflicts');
|
||||
@@ -201,7 +196,6 @@ PHP;
|
||||
|
||||
$service = new SafeUpgradeService([
|
||||
'root' => $root,
|
||||
'staging_root' => $this->tmpDir . '/staging',
|
||||
]);
|
||||
$service->clearRecoveryFlag();
|
||||
|
||||
@@ -209,12 +203,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');
|
||||
@@ -224,7 +217,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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user