bin/restore enhancement

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2025-10-17 22:18:54 -06:00
parent 70ddb549b7
commit 81b0f0ec04

View File

@@ -25,6 +25,7 @@ if (!file_exists($root . '/index.php')) {
exit(1);
}
use Grav\Common\Filesystem\Folder;
use Grav\Common\Recovery\RecoveryManager;
use Grav\Common\Upgrade\SafeUpgradeService;
use Symfony\Component\Yaml\Yaml;
@@ -39,6 +40,9 @@ Usage:
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 recovery [status|clear]
Shows the recovery flag context or clears it.
@@ -61,17 +65,35 @@ function parseArguments(array $args): array
{
array_shift($args); // remove script name
$command = $args[0] ?? 'help';
$command = null;
$arguments = [];
$options = [];
foreach (array_slice($args, 1) as $arg) {
if (substr($arg, 0, 2) === '--') {
echo "Unknown option: {$arg}\n";
exit(1);
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;
}
$arguments[] = $arg;
if (null === $command) {
$command = $arg;
} else {
$arguments[] = $arg;
}
}
if (null === $command) {
$command = 'interactive';
}
return [
@@ -81,18 +103,19 @@ function parseArguments(array $args): array
];
}
/**
* @return string|null
*/
/**
* @param array $options
* @return SafeUpgradeService
*/
function createUpgradeService(array $options): SafeUpgradeService
{
$options['root'] = GRAV_ROOT;
$serviceOptions = ['root' => GRAV_ROOT];
return new SafeUpgradeService($options);
if (isset($options['staging-root']) && is_string($options['staging-root']) && $options['staging-root'] !== '') {
$serviceOptions['staging_root'] = $options['staging-root'];
}
return new SafeUpgradeService($serviceOptions);
}
/**
@@ -119,19 +142,311 @@ function loadSnapshots(): array
'id' => $decoded['id'],
'source_version' => $decoded['source_version'] ?? null,
'target_version' => $decoded['target_version'] ?? null,
'created_at' => $decoded['created_at'] ?? 0,
'created_at' => (int)($decoded['created_at'] ?? 0),
];
}
return $snapshots;
}
/**
* @param list<array{id: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']);
return sprintf('%s (restore to Grav %s, %s)', $snapshot['id'], $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 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) {
@@ -141,12 +456,60 @@ switch ($command) {
echo "Available snapshots:\n";
foreach ($snapshots as $snapshot) {
$time = $snapshot['created_at'] ? date('c', (int)$snapshot['created_at']) : 'unknown';
$restoreVersion = $snapshot['source_version'] ?? $snapshot['target_version'] ?? 'unknown';
echo sprintf(" - %s (restore to Grav %s, %s)\n", $snapshot['id'], $restoreVersion, $time);
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) {
@@ -154,22 +517,8 @@ switch ($command) {
exit(1);
}
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";
exit(0);
applySnapshot($snapshotId, $options);
break;
case 'recovery':
$action = strtolower($arguments[0] ?? 'status');