mirror of
https://github.com/getgrav/grav.git
synced 2025-10-26 07:56:07 +01:00
635 lines
18 KiB
PHP
Executable File
635 lines
18 KiB
PHP
Executable File
#!/usr/bin/env php
|
|
<?php
|
|
|
|
/**
|
|
* Grav Snapshot Restore Utility
|
|
*
|
|
* Lightweight CLI that can list and apply safe-upgrade snapshots without
|
|
* bootstrapping the full Grav application (or any plugins).
|
|
*/
|
|
|
|
$root = dirname(__DIR__);
|
|
|
|
define('GRAV_CLI', true);
|
|
define('GRAV_REQUEST_TIME', microtime(true));
|
|
|
|
if (!file_exists($root . '/vendor/autoload.php')) {
|
|
fwrite(STDERR, "Unable to locate vendor/autoload.php. Run composer install first.\n");
|
|
exit(1);
|
|
}
|
|
|
|
$autoload = require $root . '/vendor/autoload.php';
|
|
|
|
if (!file_exists($root . '/index.php')) {
|
|
fwrite(STDERR, "FATAL: Must be run from Grav root directory.\n");
|
|
exit(1);
|
|
}
|
|
|
|
use Grav\Common\Filesystem\Folder;
|
|
use Grav\Common\Recovery\RecoveryManager;
|
|
use Grav\Common\Upgrade\SafeUpgradeService;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
const RESTORE_USAGE = <<<USAGE
|
|
Grav Restore Utility
|
|
|
|
Usage:
|
|
bin/restore list [--staging-root=/absolute/path]
|
|
Lists all available snapshots (most recent first).
|
|
|
|
bin/restore apply <snapshot-id> [--staging-root=/absolute/path]
|
|
Restores the specified snapshot created by safe-upgrade.
|
|
|
|
bin/restore remove [<snapshot-id> ...] [--staging-root=/absolute/path]
|
|
Deletes one or more snapshots (interactive selection when no id provided).
|
|
|
|
bin/restore snapshot [--label=\"optional description\"] [--staging-root=/absolute/path]
|
|
Creates a manual snapshot of the current Grav core files.
|
|
|
|
bin/restore recovery [status|clear]
|
|
Shows the recovery flag context or clears it.
|
|
|
|
Options:
|
|
--staging-root Overrides the staging directory (defaults to configured value).
|
|
--label Optional label to store with the manual snapshot.
|
|
|
|
Examples:
|
|
bin/restore list
|
|
bin/restore apply stage-68eff31cc4104
|
|
bin/restore apply stage-68eff31cc4104 --staging-root=/var/grav-backups
|
|
bin/restore snapshot --label=\"Before plugin install\"
|
|
bin/restore recovery status
|
|
bin/restore recovery clear
|
|
USAGE;
|
|
|
|
/**
|
|
* @param array $args
|
|
* @return array{command:string,arguments:array,options:array}
|
|
*/
|
|
function parseArguments(array $args): array
|
|
{
|
|
array_shift($args); // remove script name
|
|
|
|
$command = null;
|
|
$arguments = [];
|
|
$options = [];
|
|
|
|
while ($args) {
|
|
$arg = array_shift($args);
|
|
if (strncmp($arg, '--', 2) === 0) {
|
|
$parts = explode('=', substr($arg, 2), 2);
|
|
$name = $parts[0] ?? '';
|
|
if ($name === '') {
|
|
continue;
|
|
}
|
|
$value = $parts[1] ?? null;
|
|
if ($value === null && $args && substr($args[0], 0, 2) !== '--') {
|
|
$value = array_shift($args);
|
|
}
|
|
$options[$name] = $value ?? true;
|
|
continue;
|
|
}
|
|
|
|
if (null === $command) {
|
|
$command = $arg;
|
|
} else {
|
|
$arguments[] = $arg;
|
|
}
|
|
}
|
|
|
|
if (null === $command) {
|
|
$command = 'interactive';
|
|
}
|
|
|
|
return [
|
|
'command' => $command,
|
|
'arguments' => $arguments,
|
|
'options' => $options,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array $options
|
|
* @return SafeUpgradeService
|
|
*/
|
|
function createUpgradeService(array $options): SafeUpgradeService
|
|
{
|
|
$serviceOptions = ['root' => GRAV_ROOT];
|
|
|
|
if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') {
|
|
$serviceOptions['staging_root'] = $options['staging-root'];
|
|
}
|
|
|
|
return new SafeUpgradeService($serviceOptions);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}>
|
|
*/
|
|
function loadSnapshots(): array
|
|
{
|
|
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
|
if (!is_dir($manifestDir)) {
|
|
return [];
|
|
}
|
|
|
|
$files = glob($manifestDir . '/*.json') ?: [];
|
|
rsort($files);
|
|
|
|
$snapshots = [];
|
|
foreach ($files as $file) {
|
|
$decoded = json_decode(file_get_contents($file) ?: '', true);
|
|
if (!is_array($decoded) || empty($decoded['id'])) {
|
|
continue;
|
|
}
|
|
|
|
$snapshots[] = [
|
|
'id' => $decoded['id'],
|
|
'label' => $decoded['label'] ?? null,
|
|
'source_version' => $decoded['source_version'] ?? null,
|
|
'target_version' => $decoded['target_version'] ?? null,
|
|
'created_at' => (int)($decoded['created_at'] ?? 0),
|
|
];
|
|
}
|
|
|
|
return $snapshots;
|
|
}
|
|
|
|
/**
|
|
* @param list<array{id:string,label:?string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
|
* @return string
|
|
*/
|
|
function formatSnapshotListLine(array $snapshot): string
|
|
{
|
|
$restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown';
|
|
$timeLabel = formatSnapshotTimestamp($snapshot['created_at']);
|
|
$label = $snapshot['label'] ?? null;
|
|
$display = $label ? sprintf('%s [%s]', $label, $snapshot['id']) : $snapshot['id'];
|
|
|
|
return sprintf('%s (restore to Grav %s, %s)', $display, $restoreVersion, $timeLabel);
|
|
}
|
|
|
|
function formatSnapshotTimestamp(int $timestamp): string
|
|
{
|
|
if ($timestamp <= 0) {
|
|
return 'time unknown';
|
|
}
|
|
|
|
try {
|
|
$timezone = resolveTimezone();
|
|
$dt = new DateTime('@' . $timestamp);
|
|
$dt->setTimezone($timezone);
|
|
$formatted = $dt->format('Y-m-d H:i:s T');
|
|
} catch (\Throwable $e) {
|
|
$formatted = date('Y-m-d H:i:s T', $timestamp);
|
|
}
|
|
|
|
return $formatted . ' (' . formatRelative(time() - $timestamp) . ')';
|
|
}
|
|
|
|
function resolveTimezone(): DateTimeZone
|
|
{
|
|
static $resolved = null;
|
|
if ($resolved instanceof DateTimeZone) {
|
|
return $resolved;
|
|
}
|
|
|
|
$timezone = null;
|
|
$configFile = GRAV_ROOT . '/user/config/system.yaml';
|
|
if (is_file($configFile)) {
|
|
try {
|
|
$data = Yaml::parse(file_get_contents($configFile) ?: '') ?: [];
|
|
if (!empty($data['system']['timezone']) && is_string($data['system']['timezone'])) {
|
|
$timezone = $data['system']['timezone'];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// ignore parse errors, fallback below
|
|
}
|
|
}
|
|
|
|
if (!$timezone) {
|
|
$timezone = ini_get('date.timezone') ?: 'UTC';
|
|
}
|
|
|
|
try {
|
|
$resolved = new DateTimeZone($timezone);
|
|
} catch (\Throwable $e) {
|
|
$resolved = new DateTimeZone('UTC');
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
function formatRelative(int $seconds): string
|
|
{
|
|
if ($seconds < 5) {
|
|
return 'just now';
|
|
}
|
|
$negative = $seconds < 0;
|
|
$seconds = abs($seconds);
|
|
$units = [
|
|
31536000 => 'y',
|
|
2592000 => 'mo',
|
|
604800 => 'w',
|
|
86400 => 'd',
|
|
3600 => 'h',
|
|
60 => 'm',
|
|
1 => 's',
|
|
];
|
|
foreach ($units as $size => $label) {
|
|
if ($seconds >= $size) {
|
|
$value = (int)floor($seconds / $size);
|
|
$suffix = $label === 'mo' ? 'month' : ($label === 'y' ? 'year' : ($label === 'w' ? 'week' : ($label === 'd' ? 'day' : ($label === 'h' ? 'hour' : ($label === 'm' ? 'minute' : 'second')))));
|
|
if ($value !== 1) {
|
|
$suffix .= 's';
|
|
}
|
|
$phrase = $value . ' ' . $suffix;
|
|
return $negative ? 'in ' . $phrase : $phrase . ' ago';
|
|
}
|
|
}
|
|
|
|
return $negative ? 'in 0 seconds' : '0 seconds ago';
|
|
}
|
|
|
|
/**
|
|
* @param string $snapshotId
|
|
* @param array $options
|
|
* @return void
|
|
*/
|
|
function applySnapshot(string $snapshotId, array $options): void
|
|
{
|
|
try {
|
|
$service = createUpgradeService($options);
|
|
$manifest = $service->rollback($snapshotId);
|
|
} catch (\Throwable $e) {
|
|
fwrite(STDERR, "Restore failed: " . $e->getMessage() . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
if (!$manifest) {
|
|
fwrite(STDERR, "Snapshot {$snapshotId} not found.\n");
|
|
exit(1);
|
|
}
|
|
|
|
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
|
|
echo "Restored snapshot {$snapshotId} (Grav {$version}).\n";
|
|
if (!empty($manifest['id'])) {
|
|
echo "Snapshot manifest: {$manifest['id']}\n";
|
|
}
|
|
if (!empty($manifest['backup_path'])) {
|
|
echo "Snapshot path: {$manifest['backup_path']}\n";
|
|
}
|
|
exit(0);
|
|
}
|
|
|
|
/**
|
|
* @param array $options
|
|
* @return void
|
|
*/
|
|
function createManualSnapshot(array $options): void
|
|
{
|
|
$label = null;
|
|
if (isset($options['label']) && is_string($options['label'])) {
|
|
$label = trim($options['label']);
|
|
if ($label === '') {
|
|
$label = null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$service = createUpgradeService($options);
|
|
$manifest = $service->createSnapshot($label);
|
|
} catch (\Throwable $e) {
|
|
fwrite(STDERR, "Snapshot creation failed: " . $e->getMessage() . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
$snapshotId = $manifest['id'] ?? null;
|
|
if (!$snapshotId) {
|
|
$snapshotId = 'unknown';
|
|
}
|
|
$version = $manifest['source_version'] ?? $manifest['target_version'] ?? 'unknown';
|
|
|
|
echo "Created snapshot {$snapshotId} (Grav {$version}).\n";
|
|
if ($label) {
|
|
echo "Label: {$label}\n";
|
|
}
|
|
if (!empty($manifest['backup_path'])) {
|
|
echo "Snapshot path: {$manifest['backup_path']}\n";
|
|
}
|
|
|
|
exit(0);
|
|
}
|
|
|
|
/**
|
|
* @param list<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
|
* @return string|null
|
|
*/
|
|
function promptSnapshotSelection(array $snapshots): ?string
|
|
{
|
|
echo "Available snapshots:\n";
|
|
foreach ($snapshots as $index => $snapshot) {
|
|
$line = formatSnapshotListLine($snapshot);
|
|
$number = $index + 1;
|
|
echo sprintf(" [%d] %s\n", $number, $line);
|
|
}
|
|
|
|
$default = $snapshots[0]['id'];
|
|
echo "\nSelect a snapshot to restore [1]: ";
|
|
$input = trim((string)fgets(STDIN));
|
|
|
|
if ($input === '') {
|
|
return $default;
|
|
}
|
|
|
|
if (ctype_digit($input)) {
|
|
$idx = (int)$input - 1;
|
|
if (isset($snapshots[$idx])) {
|
|
return $snapshots[$idx]['id'];
|
|
}
|
|
}
|
|
|
|
foreach ($snapshots as $snapshot) {
|
|
if (strcasecmp($snapshot['id'], $input) === 0) {
|
|
return $snapshot['id'];
|
|
}
|
|
}
|
|
|
|
echo "Invalid selection. Aborting.\n";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param list<array{id:string,source_version:?string,target_version:?string,created_at:int}> $snapshots
|
|
* @return array<string>
|
|
*/
|
|
function promptSnapshotsRemoval(array $snapshots): array
|
|
{
|
|
echo "Available snapshots:\n";
|
|
foreach ($snapshots as $index => $snapshot) {
|
|
$line = formatSnapshotListLine($snapshot);
|
|
$number = $index + 1;
|
|
echo sprintf(" [%d] %s\n", $number, $line);
|
|
}
|
|
|
|
echo "\nSelect snapshots to remove (comma or space separated numbers / ids, 'all' for everything, empty to cancel): ";
|
|
$input = trim((string)fgets(STDIN));
|
|
|
|
if ($input === '') {
|
|
return [];
|
|
}
|
|
|
|
$inputLower = strtolower($input);
|
|
if ($inputLower === 'all' || $inputLower === '*') {
|
|
return array_values(array_unique(array_column($snapshots, 'id')));
|
|
}
|
|
|
|
$tokens = preg_split('/[\\s,]+/', $input, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
$selected = [];
|
|
foreach ($tokens as $token) {
|
|
if (ctype_digit($token)) {
|
|
$idx = (int)$token - 1;
|
|
if (isset($snapshots[$idx])) {
|
|
$selected[] = $snapshots[$idx]['id'];
|
|
continue;
|
|
}
|
|
}
|
|
|
|
foreach ($snapshots as $snapshot) {
|
|
if (strcasecmp($snapshot['id'], $token) === 0) {
|
|
$selected[] = $snapshot['id'];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique(array_filter($selected)));
|
|
}
|
|
|
|
/**
|
|
* @param string $snapshotId
|
|
* @return array{success:bool,message:string}
|
|
*/
|
|
function removeSnapshot(string $snapshotId): array
|
|
{
|
|
$manifestDir = GRAV_ROOT . '/user/data/upgrades';
|
|
$manifestPath = $manifestDir . '/' . $snapshotId . '.json';
|
|
if (!is_file($manifestPath)) {
|
|
return [
|
|
'success' => false,
|
|
'message' => "Snapshot {$snapshotId} not found."
|
|
];
|
|
}
|
|
|
|
$manifest = json_decode(file_get_contents($manifestPath) ?: '', true);
|
|
if (!is_array($manifest)) {
|
|
return [
|
|
'success' => false,
|
|
'message' => "Snapshot {$snapshotId} manifest is invalid."
|
|
];
|
|
}
|
|
|
|
$pathsToDelete = [];
|
|
foreach (['package_path', 'backup_path'] as $key) {
|
|
if (!empty($manifest[$key]) && is_string($manifest[$key])) {
|
|
$pathsToDelete[] = $manifest[$key];
|
|
}
|
|
}
|
|
|
|
$errors = [];
|
|
|
|
foreach ($pathsToDelete as $path) {
|
|
if (!$path) {
|
|
continue;
|
|
}
|
|
if (!file_exists($path)) {
|
|
continue;
|
|
}
|
|
try {
|
|
if (is_dir($path)) {
|
|
Folder::delete($path);
|
|
} else {
|
|
@unlink($path);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$errors[] = "Unable to remove {$path}: " . $e->getMessage();
|
|
}
|
|
}
|
|
|
|
if (!@unlink($manifestPath)) {
|
|
$errors[] = "Unable to delete manifest file {$manifestPath}.";
|
|
}
|
|
|
|
if ($errors) {
|
|
return [
|
|
'success' => false,
|
|
'message' => implode(' ', $errors)
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => "Removed snapshot {$snapshotId}."
|
|
];
|
|
}
|
|
|
|
$cli = parseArguments($argv);
|
|
$command = $cli['command'];
|
|
$arguments = $cli['arguments'];
|
|
$options = $cli['options'];
|
|
|
|
switch ($command) {
|
|
case 'interactive':
|
|
$snapshots = loadSnapshots();
|
|
if (!$snapshots) {
|
|
echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n";
|
|
exit(0);
|
|
}
|
|
|
|
$selection = promptSnapshotSelection($snapshots);
|
|
if (!$selection) {
|
|
exit(1);
|
|
}
|
|
|
|
applySnapshot($selection, $options);
|
|
break;
|
|
|
|
case 'list':
|
|
$snapshots = loadSnapshots();
|
|
if (!$snapshots) {
|
|
echo "No snapshots found. Run bin/gpm self-upgrade (with safe upgrade enabled) to create one.\n";
|
|
exit(0);
|
|
}
|
|
|
|
echo "Available snapshots:\n";
|
|
foreach ($snapshots as $snapshot) {
|
|
echo ' - ' . formatSnapshotListLine($snapshot) . "\n";
|
|
}
|
|
exit(0);
|
|
|
|
case 'remove':
|
|
$snapshots = loadSnapshots();
|
|
if (!$snapshots) {
|
|
echo "No snapshots found. Nothing to remove.\n";
|
|
exit(0);
|
|
}
|
|
|
|
$selectedIds = [];
|
|
if ($arguments) {
|
|
foreach ($arguments as $arg) {
|
|
if (!$arg) {
|
|
continue;
|
|
}
|
|
$selectedIds[] = $arg;
|
|
}
|
|
} else {
|
|
$selectedIds = promptSnapshotsRemoval($snapshots);
|
|
if (!$selectedIds) {
|
|
echo "No snapshots selected. Aborting.\n";
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
$selectedIds = array_values(array_unique($selectedIds));
|
|
echo "Snapshots selected for removal:\n";
|
|
foreach ($selectedIds as $id) {
|
|
echo " - {$id}\n";
|
|
}
|
|
|
|
$autoConfirm = isset($options['yes']) || isset($options['y']);
|
|
if (!$autoConfirm) {
|
|
echo "\nThis action cannot be undone. Proceed? [y/N] ";
|
|
$confirmation = strtolower(trim((string)fgets(STDIN)));
|
|
if (!in_array($confirmation, ['y', 'yes'], true)) {
|
|
echo "Aborted.\n";
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
$success = 0;
|
|
foreach ($selectedIds as $id) {
|
|
$result = removeSnapshot($id);
|
|
echo $result['message'] . "\n";
|
|
if ($result['success']) {
|
|
$success++;
|
|
}
|
|
}
|
|
|
|
exit($success > 0 ? 0 : 1);
|
|
|
|
case 'apply':
|
|
$snapshotId = $arguments[0] ?? null;
|
|
if (!$snapshotId) {
|
|
echo "Missing snapshot id.\n\n" . RESTORE_USAGE . "\n";
|
|
exit(1);
|
|
}
|
|
|
|
applySnapshot($snapshotId, $options);
|
|
break;
|
|
|
|
case 'snapshot':
|
|
createManualSnapshot($options);
|
|
break;
|
|
|
|
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";
|
|
|
|
$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:
|
|
echo "Unknown recovery action: {$action}\n\n" . RESTORE_USAGE . "\n";
|
|
exit(1);
|
|
}
|
|
|
|
case 'help':
|
|
default:
|
|
echo RESTORE_USAGE . "\n";
|
|
exit($command === 'help' ? 0 : 1);
|
|
}
|