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')
|
->weeklyOn(1, '09:00')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Backup
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Settings for `php artisan takeone:backup`.
|
||||||
|
|
|
||||||
|
| The database and the media library are backed up by DIFFERENT mechanisms on
|
||||||
|
| purpose. The database is small and must be captured as a consistent
|
||||||
|
| point-in-time snapshot, so it is copied in full on every run. The media
|
||||||
|
| library is gigabytes of immutable uploads, so it is mirrored incrementally
|
||||||
|
| and only when asked — tarring it nightly would burn hours of CPU and fill
|
||||||
|
| the disk with near-identical archives.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// Where snapshots are written. Lives under /data, which is git-ignored, so
|
||||||
|
// a backup can never be committed by accident.
|
||||||
|
'path' => env('BACKUP_PATH', base_path('data/backups')),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Retention
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Artifacts older than retain_days are pruned, but the newest keep_minimum
|
||||||
|
| are ALWAYS kept regardless of age — a box that sat idle for a month must
|
||||||
|
| still have something to restore from.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'retain_days' => (int) env('BACKUP_RETAIN_DAYS', 14),
|
||||||
|
'keep_minimum' => (int) env('BACKUP_KEEP_MINIMUM', 3),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Off-server copy
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| A filesystems.php disk name. A snapshot sitting on the same disk as the
|
||||||
|
| data survives a bad migration but not a dead disk, so set this to a
|
||||||
|
| remote disk. The command warns on every run while it is unset.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'disk' => env('BACKUP_DISK'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Media library
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Uploaded video is the irreplaceable asset here — it cannot be regenerated
|
||||||
|
| from anything. It is mirrored with rsync (incremental, resumable) to
|
||||||
|
| media_destination, which should be a NAS mount or another machine.
|
||||||
|
|
|
||||||
|
| Only runs when --media is passed AND a destination is set.
|
||||||
|
|
|
||||||
|
| media_delete stays false by default: a mirror that deletes is one bad
|
||||||
|
| path away from erasing the copy it was meant to protect.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'media_paths' => [
|
||||||
|
base_path('data/app/users'),
|
||||||
|
base_path('data/app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'media_destination' => env('BACKUP_MEDIA_DEST'),
|
||||||
|
|
||||||
|
'media_delete' => (bool) env('BACKUP_MEDIA_DELETE', false),
|
||||||
|
|
||||||
|
];
|
||||||
Reference in new issue
Block a user