chore(backup): ship takeone:backup command + nightly scheduler entry
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]>
This commit is contained in:
1 parent
e76ac39f99
commit
8ab59fc1bb
3 files changed
+439
No files matched your search
@@ -0,0 +1,357 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel
|
||||
->weeklyOn(1, '09:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// Nightly database backup at 03:30 Asia/Bahrain. Writes to
|
||||
// data/backups/db-YYYYMMDD-HHMMSS.sqlite, verifies the snapshot,
|
||||
// and (when BACKUP_DISK is set) ships a copy off-server.
|
||||
$schedule->command('takeone:backup')
|
||||
->dailyAt('03:30')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user