Moves BackupCommand and config/backup.php from the pre-audit WIP that got stashed on 2026-10-21 to master, and registers the command in the Laravel scheduler so it runs at 03:30 Asia/Bahrain nightly. The command handles sqlite (VACUUM INTO after a WAL checkpoint) and mysql (mysqldump | gzip, verified with gzip -t) backends; verifies every snapshot by re-opening it with PDO + PRAGMA integrity_check + a sanity SELECT; ships an off-server copy via Storage::disk() when BACKUP_DISK is set; and prunes artifacts older than BACKUP_RETAIN_DAYS while always keeping at least BACKUP_KEEP_MINIMUM. Scheduler entry mirrors the nas-auto-sync shape — withoutOverlapping + runInBackground so a slow upload doesn't block the next tick. .env knobs: BACKUP_PATH (default: data/backups) BACKUP_RETAIN_DAYS (default: 14) BACKUP_KEEP_MINIMUM (default: 3) BACKUP_DISK (unset — warns on every run until configured) BACKUP_MEDIA_DEST (unset — required when running with --media) BACKUP_MEDIA_DELETE (default: false) Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
358 lines
12 KiB
PHP
358 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use PDO;
|
||
|
||
/**
|
||
* Backs up the database, and optionally mirrors the media library.
|
||
*
|
||
* Three things make this a backup rather than a file copy:
|
||
*
|
||
* 1. CONSISTENCY — SQLite is snapshotted with `VACUUM INTO` after a WAL
|
||
* checkpoint, so the artifact is a coherent point-in-time copy even while
|
||
* the app is writing. Copying the file by hand mid-write is not.
|
||
* 2. VERIFICATION — the snapshot is re-opened, integrity-checked and read
|
||
* back before it counts as a success. An unverified backup is a rumour.
|
||
* 3. OFF-SERVER — a snapshot on the same disk survives a bad migration but
|
||
* not a dead disk, so a configured remote disk receives a copy and the
|
||
* command warns loudly on every run where none is set.
|
||
*
|
||
* The media library is deliberately NOT part of the default run. It is
|
||
* gigabytes of immutable uploads; archiving it nightly would produce hours of
|
||
* CPU and a disk full of near-identical tarballs. Pass --media to mirror it
|
||
* incrementally with rsync instead.
|
||
*
|
||
* Exits non-zero on any failure so a cron surfaces the problem instead of
|
||
* quietly producing corrupt files.
|
||
*
|
||
* Mirrors `takeone:backup` on the takeone platform so both systems are backed
|
||
* up the same way and restored the same way.
|
||
*/
|
||
class BackupCommand extends Command
|
||
{
|
||
protected $signature = 'takeone:backup
|
||
{--media : Also mirror the media library (incremental rsync, needs BACKUP_MEDIA_DEST)}
|
||
{--media-only : Mirror media and skip the database}
|
||
{--keep-local : Do not delete the local copy after an off-server upload}';
|
||
|
||
protected $description = 'Snapshot the database, verify it, optionally mirror media, copy off-server and prune old artifacts';
|
||
|
||
private array $artifacts = [];
|
||
|
||
public function handle(): int
|
||
{
|
||
$dir = (string) config('backup.path');
|
||
|
||
if (! is_dir($dir) && ! @mkdir($dir, 0775, true)) {
|
||
$this->error("Cannot create backup directory: {$dir}");
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$stamp = now()->format('Ymd-His');
|
||
$failed = false;
|
||
|
||
if (! $this->option('media-only')) {
|
||
$failed = ! $this->backupDatabase($dir, $stamp) || $failed;
|
||
}
|
||
|
||
if ($this->option('media') || $this->option('media-only')) {
|
||
$failed = ! $this->mirrorMedia() || $failed;
|
||
} else {
|
||
$this->line(' <comment>–</comment> media not included (pass --media to mirror it)');
|
||
}
|
||
|
||
$this->shipOffServer();
|
||
$this->prune($dir);
|
||
|
||
if ($failed) {
|
||
Log::error('Backup run failed', ['artifacts' => $this->artifacts]);
|
||
$this->error('Backup FAILED — see the errors above.');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]);
|
||
$this->newLine();
|
||
$this->info('Backup complete.');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/* ---------------- Database ---------------- */
|
||
|
||
private function backupDatabase(string $dir, string $stamp): bool
|
||
{
|
||
$connection = config('database.default');
|
||
$driver = config("database.connections.{$connection}.driver");
|
||
|
||
return match ($driver) {
|
||
'sqlite' => $this->backupSqlite($dir, $stamp),
|
||
'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection),
|
||
default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")),
|
||
};
|
||
}
|
||
|
||
/** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */
|
||
private function backupSqlite(string $dir, string $stamp): bool
|
||
{
|
||
$target = $dir."/db-{$stamp}.sqlite";
|
||
|
||
// VACUUM cannot run inside a transaction. Rather than let SQLite throw
|
||
// something cryptic, say so plainly — a backup that quietly produces
|
||
// nothing is the worst possible outcome.
|
||
if (DB::transactionLevel() > 0) {
|
||
$this->error('Cannot snapshot the database inside an open transaction.');
|
||
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// Harmless when the database is not in WAL mode.
|
||
DB::statement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||
DB::statement('VACUUM INTO '.DB::getPdo()->quote($target));
|
||
} catch (\Throwable $e) {
|
||
$this->error('Database snapshot failed: '.$e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
|
||
if (! $this->verifySqlite($target)) {
|
||
return false;
|
||
}
|
||
|
||
$this->artifacts[] = $target;
|
||
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Read the snapshot back. A file that exists but cannot be opened, or that
|
||
* holds no migrations and no videos, is not a backup worth trusting.
|
||
*/
|
||
private function verifySqlite(string $path): bool
|
||
{
|
||
try {
|
||
$pdo = new PDO('sqlite:'.$path);
|
||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
|
||
if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') {
|
||
$this->error("Snapshot failed integrity check: {$path}");
|
||
|
||
return false;
|
||
}
|
||
|
||
$migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn();
|
||
|
||
if ($migrations < 1) {
|
||
$this->error("Snapshot contains no migrations — refusing to trust it: {$path}");
|
||
|
||
return false;
|
||
}
|
||
|
||
// Read a real application table too. `migrations` alone would still
|
||
// be present in a snapshot taken of an empty database.
|
||
$videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn();
|
||
$this->line(" <info>✓</info> verified integrity ok · {$migrations} migrations · {$videos} videos");
|
||
} catch (\Throwable $e) {
|
||
$this->error('Snapshot could not be verified: '.$e->getMessage());
|
||
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private function backupMysql(string $dir, string $stamp, string $connection): bool
|
||
{
|
||
$target = $dir."/db-{$stamp}.sql.gz";
|
||
$c = config("database.connections.{$connection}");
|
||
|
||
$command = sprintf(
|
||
'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s',
|
||
escapeshellarg((string) $c['host']),
|
||
escapeshellarg((string) $c['port']),
|
||
escapeshellarg((string) $c['username']),
|
||
$c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '',
|
||
escapeshellarg((string) $c['database']),
|
||
escapeshellarg($target),
|
||
);
|
||
|
||
exec($command, $out, $status);
|
||
|
||
if ($status !== 0 || ! is_file($target) || filesize($target) === 0) {
|
||
$this->error('mysqldump failed (exit '.$status.').');
|
||
|
||
return false;
|
||
}
|
||
|
||
// Verify the dump is readable and non-trivial rather than trusting the
|
||
// exit code — a gzip of an error message also exits 0.
|
||
exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify);
|
||
|
||
if ($verify !== 0) {
|
||
$this->error('Dump failed its gzip integrity check — discarding.');
|
||
@unlink($target);
|
||
|
||
return false;
|
||
}
|
||
|
||
$this->artifacts[] = $target;
|
||
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
|
||
|
||
return true;
|
||
}
|
||
|
||
/* ---------------- Media ---------------- */
|
||
|
||
/**
|
||
* Mirror the media library with rsync.
|
||
*
|
||
* Uploaded video cannot be regenerated from anything — losing it is the
|
||
* one unrecoverable outcome on this platform. rsync is used rather than an
|
||
* archive because the library is large and almost entirely immutable, so
|
||
* each run copies only what is new.
|
||
*/
|
||
private function mirrorMedia(): bool
|
||
{
|
||
$destination = (string) config('backup.media_destination');
|
||
|
||
if ($destination === '') {
|
||
$this->newLine();
|
||
$this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.');
|
||
$this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.');
|
||
|
||
return false;
|
||
}
|
||
|
||
if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) {
|
||
$this->error("Media destination is not reachable: {$destination}");
|
||
|
||
return false;
|
||
}
|
||
|
||
$paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir'));
|
||
|
||
if (! $paths) {
|
||
$this->line(' <comment>–</comment> media nothing to mirror');
|
||
|
||
return true;
|
||
}
|
||
|
||
// --delete is opt-in and off by default: a mirror that deletes is one
|
||
// wrong path away from erasing the copy it exists to protect.
|
||
$flags = '-a --partial --info=stats2';
|
||
|
||
if (config('backup.media_delete')) {
|
||
$flags .= ' --delete';
|
||
}
|
||
|
||
$ok = true;
|
||
|
||
foreach ($paths as $path) {
|
||
// Trailing slash on the source, basename on the destination, so
|
||
// each folder lands in its own directory rather than merging.
|
||
$command = sprintf(
|
||
'rsync %s %s %s 2>&1',
|
||
$flags,
|
||
escapeshellarg(rtrim($path, '/').'/'),
|
||
escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'),
|
||
);
|
||
|
||
exec($command, $out, $status);
|
||
|
||
if ($status !== 0) {
|
||
$this->error('Media mirror failed for '.basename($path).' (exit '.$status.').');
|
||
$ok = false;
|
||
|
||
continue;
|
||
}
|
||
|
||
$this->line(sprintf(' <info>✓</info> media %s → %s', basename($path), $destination));
|
||
}
|
||
|
||
return $ok;
|
||
}
|
||
|
||
/* ---------------- Off-server ---------------- */
|
||
|
||
private function shipOffServer(): void
|
||
{
|
||
$disk = config('backup.disk');
|
||
|
||
if (! $disk) {
|
||
$this->newLine();
|
||
$this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.');
|
||
$this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.');
|
||
|
||
return;
|
||
}
|
||
|
||
foreach ($this->artifacts as $artifact) {
|
||
try {
|
||
Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb'));
|
||
$this->line(" <info>✓</info> off-server {$disk}:backups/".basename($artifact));
|
||
|
||
if (! $this->option('keep-local')) {
|
||
@unlink($artifact);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// Never delete the local copy when the upload failed — it is
|
||
// the only copy left.
|
||
$this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------------- Rotation ---------------- */
|
||
|
||
/**
|
||
* Delete artifacts past the retention window, but always keep the newest
|
||
* few — a box that sat idle for a month must still have something to
|
||
* restore from.
|
||
*/
|
||
private function prune(string $dir): void
|
||
{
|
||
$days = (int) config('backup.retain_days', 14);
|
||
|
||
if ($days <= 0) {
|
||
return;
|
||
}
|
||
|
||
$keep = max(0, (int) config('backup.keep_minimum', 3));
|
||
$cutoff = now()->subDays($days)->getTimestamp();
|
||
$removed = 0;
|
||
|
||
$files = glob($dir.'/db-*') ?: [];
|
||
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first
|
||
|
||
foreach (array_slice($files, $keep) as $file) {
|
||
if (filemtime($file) < $cutoff && @unlink($file)) {
|
||
$removed++;
|
||
}
|
||
}
|
||
|
||
if ($removed) {
|
||
$this->line(" <info>✓</info> pruned {$removed} artifact(s) older than {$days} days");
|
||
}
|
||
}
|
||
|
||
private function humanSize(string $path): string
|
||
{
|
||
$bytes = (int) @filesize($path);
|
||
$units = ['B', 'KB', 'MB', 'GB'];
|
||
|
||
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
|
||
$bytes /= 1024;
|
||
}
|
||
|
||
return round($bytes, 1).' '.$units[$i];
|
||
}
|
||
}
|