Files
ghassanandClaude Opus 4.7 8ab59fc1bb 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]>
2026-10-21 13:14:21 +03:00

358 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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];
}
}