Raiju/cli/backup.php
2026-10-25 06:05:42 +03:00

245 lines
9.1 KiB
PHP
Executable File

#!/usr/bin/env php
<?php
/**
* Raiju site backup CLI runner.
*
* Designed to be wired into a system cron job for periodic full-site
* backups. Reads its config from content/settings.json under the
* `backup` key (see CLAUDE.md > Site Backups).
*
* Usage:
* php cli/backup.php --action=run # one full backup + prune
* php cli/backup.php --action=prune # apply retention only
* php cli/backup.php --action=status # show current state
* php cli/backup.php --action=list # list backup files
* php cli/backup.php --action=upload-remote --file=example.com-backup-...
*
* Cron example (one nightly run):
* 0 3 * * * php /path/to/site/cli/backup.php --action=run
*
* Exit codes:
* 0 success (or status/list completed)
* 1 failure (config missing, ZIP error, etc.)
* 2 invalid arguments
* 3 another run is in progress (lock contention)
*/
// Command-line tools must never run as a web request, even where a server ignores .htaccess.
if (PHP_SAPI !== 'cli' && isset($_SERVER['REQUEST_METHOD'])) {
http_response_code(404);
exit;
}
$projectRoot = dirname(__DIR__);
if (!file_exists($projectRoot . '/route.php')) {
fwrite(STDERR, "Error: Run this script from the Raiju project root.\n");
fwrite(STDERR, " php /path/to/raiju/cli/backup.php --action=run\n");
exit(1);
}
// ── CLI argument parsing ──────────────────────────────────────────────
$opts = [];
for ($i = 1; $i < count($argv); $i++) {
$a = $argv[$i];
if (preg_match('/^--([a-z-]+)=(.+)$/', $a, $m)) {
$opts[$m[1]] = $m[2];
} elseif (preg_match('/^--([a-z-]+)$/', $a, $m)) {
$opts[$m[1]] = true;
}
}
if (isset($opts['help']) || !isset($opts['action'])) {
echo <<<USAGE
Raiju Site Backup CLI
Usage:
php cli/backup.php --action=ACTION
Actions:
run Create a new backup (auto-tier) and apply retention.
This is what cron typically calls.
prune Apply retention rules and storage budget without creating
a new backup. Useful after manually changing settings.
status Print current backup state, last run, retention settings.
list List backup files with size and tier.
upload-remote
Upload an existing backup file to enabled remote targets.
Options:
--tier=TIER Force a specific tier (daily|weekly|monthly|yearly|manual)
instead of letting backupTierFor() pick. For run only.
--skip-remote Create/prune locally without uploading remote targets.
--file=FILENAME Backup filename for upload-remote.
--target=ID Upload to one configured remote target ID only.
--quiet Suppress per-file output, just print final summary.
--help Show this help.
Exit codes:
0 success
1 failure
2 invalid arguments
3 lock contention (another run in progress)
USAGE;
exit(isset($opts['help']) ? 0 : 2);
}
$action = $opts['action'];
$quiet = !empty($opts['quiet']);
$configFile = $projectRoot . '/admin/config.php';
if (!is_file($configFile)) {
fwrite(STDERR, "Error: admin/config.php not found. Run admin/setup.php first.\n");
exit(1);
}
require_once $configFile;
require_once $projectRoot . '/includes/backup-helper.php';
if (!defined('BACKUP_PATH')) {
fwrite(STDERR, "Error: BACKUP_PATH not defined. Run admin/setup.php first.\n");
exit(1);
}
if (!is_dir(BACKUP_PATH)) {
@mkdir(BACKUP_PATH, 0755, true);
}
// ── Helpers ───────────────────────────────────────────────────────────
function fmtSize($bytes) {
if ($bytes >= 1024 * 1024 * 1024) return number_format($bytes / 1024 / 1024 / 1024, 2) . ' GB';
if ($bytes >= 1024 * 1024) return number_format($bytes / 1024 / 1024, 1) . ' MB';
if ($bytes >= 1024) return number_format($bytes / 1024, 1) . ' KB';
return $bytes . ' B';
}
// ── Actions ───────────────────────────────────────────────────────────
switch ($action) {
case 'run':
$tier = $opts['tier'] ?? null;
$config = backupConfig();
if (!$config['enabled'] && !isset($opts['tier'])) {
// Allow manual --tier override even when scheduled backups are off.
fwrite(STDERR, "Backups are disabled in settings. Enable via dashboard or use --tier=manual to force one run.\n");
exit(0);
}
try {
$skipRemote = !empty($GLOBALS['opts']['skip-remote']);
$result = backupWithLock(function() use ($tier, $skipRemote) {
$started = time();
if (!($GLOBALS['quiet'] ?? false)) {
echo "[" . date('c', $started) . "] Backup run starting (tier="
. ($tier ?? 'auto') . ")...\n";
}
return backupRun($started, $tier, !$skipRemote);
});
} catch (BackupLockException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(3);
}
$created = $result['created'];
if ($created['ok']) {
echo "Created: {$created['file']} (" . fmtSize($created['size']) . ", tier={$created['tier']})\n";
if (!empty($result['deleted'])) {
echo "Pruned: " . count($result['deleted']) . " older backup(s)\n";
if (!$quiet) {
foreach ($result['deleted'] as $f) echo " - $f\n";
}
}
if (!$quiet && !empty($result['remote'])) {
echo "Remote uploads:\n";
foreach ($result['remote'] as $remote) {
echo " - {$remote['name']}: " . ($remote['ok'] ? 'ok' : 'failed') . " ({$remote['message']})\n";
}
}
$status = backupStatus();
echo "Total stored: {$status['count']} backups, " . fmtSize($status['total_bytes']) . "\n";
exit(0);
} else {
fwrite(STDERR, "Backup failed: " . $created['message'] . "\n");
exit(1);
}
case 'prune':
$deleted = backupWithLock(fn() => backupPrune());
echo "Pruned: " . count($deleted) . " backup(s)\n";
if (!$quiet) {
foreach ($deleted as $f) echo " - $f\n";
}
$status = backupStatus();
echo "Remaining: {$status['count']} backups, " . fmtSize($status['total_bytes']) . "\n";
exit(0);
case 'status':
$s = backupStatus();
echo "Scheduled backups: " . ($s['enabled'] ? 'enabled' : 'disabled') . "\n";
echo "Retention:\n";
foreach ($s['retention'] as $tier => $n) {
echo " $tier: keep $n\n";
}
echo "Storage limit: " . ($s['storage_limit_mb'] ? $s['storage_limit_mb'] . ' MB' : 'unlimited') . "\n";
echo "\n";
echo "Stored: {$s['count']} backups, " . fmtSize($s['total_bytes']) . "\n";
echo "Newest: " . ($s['newest'] ? date('c', $s['newest']) : '-') . "\n";
echo "Oldest: " . ($s['oldest'] ? date('c', $s['oldest']) : '-') . "\n";
echo "Last run: " . ($s['last_run'] ?: '-') . " (" . ($s['last_status'] ?: '-') . ")\n";
if ($s['last_message']) {
echo "Last message: {$s['last_message']}\n";
}
echo "Remote targets: " . count($s['remote_targets']) . "\n";
foreach ($s['remote_targets'] as $target) {
echo " {$target['id']} ({$target['type']}): "
. ($target['enabled'] ? 'enabled' : 'disabled')
. ', last=' . ($target['last_status'] ?: '-') . "\n";
}
exit(0);
case 'list':
$list = backupListAll();
if (empty($list)) {
echo "No backups found.\n";
exit(0);
}
echo str_pad('FILE', 60) . str_pad('TIER', 10) . str_pad('SIZE', 12) . "DATE\n";
echo str_repeat('-', 100) . "\n";
foreach ($list as $b) {
echo str_pad($b['file'], 60)
. str_pad($b['tier'], 10)
. str_pad(fmtSize($b['size']), 12)
. date('Y-m-d H:i:s', $b['mtime']) . "\n";
}
exit(0);
case 'upload-remote':
$file = $opts['file'] ?? '';
if (!backupIsPoolFilename($file)) {
fwrite(STDERR, "Invalid or missing --file value.\n");
exit(2);
}
if (!is_file(BACKUP_PATH . $file)) {
fwrite(STDERR, "Backup file not found: $file\n");
exit(1);
}
try {
$results = backupWithLock(fn() => backupUploadRemoteTargets($file, $opts['target'] ?? null));
} catch (BackupLockException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(3);
}
$failed = 0;
foreach ($results as $remote) {
echo "{$remote['name']}: " . ($remote['ok'] ? 'ok' : 'failed') . " ({$remote['message']})\n";
if (!$remote['ok']) $failed++;
}
exit($failed > 0 ? 1 : 0);
default:
fwrite(STDERR, "Unknown action: $action (try --help)\n");
exit(2);
}