245 lines
9.1 KiB
PHP
Executable File
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);
|
|
}
|