feat: db backup/restore/prune commands with APP_KEY encryption

Three new artisan commands modelled on blax-intranet's database
backup/restore pattern, generalised so any project pulling in
laravel-workkit gets them for free:

- workkit:db:backup
  mysqldump → xz -9 → Crypt::encryptString → storage/backups/db_<conn>_<ts>.sql.xz.enc
  Password is passed via MYSQL_PWD so it doesn't appear in `ps`.
- workkit:db:restore
  Picks newest backup (or --file=…), inverts the pipeline, and pipes
  the .sql into the mysql CLI. Detects .enc / .xz suffixes so plain
  dumps and partially-encoded files work too. Confirms before
  overwriting unless --force is set; emits a clear "APP_KEY
  mismatch" message when Crypt::decryptString fails.
- workkit:db:prune-backups
  Drops backups older than --days (defaults to 30 / config).
  --dry-run shows what would go.

Encryption uses Laravel's Crypt facade, so the AES-256 key is
derived from APP_KEY — same key the rest of the app uses for cookies
and encrypted columns. A backup is restorable only by a deployment
that knows the host's APP_KEY.

Shipped config/workkit.php (mergeConfigFrom + publishes) for
overriding the backup directory and retention window without
hard-coding env-specific values in the package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian Wagner 2026-04-29 12:01:32 +02:00
parent aa51e0aa27
commit aacd486a40
6 changed files with 564 additions and 1 deletions

20
config/workkit.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Backup Settings
|--------------------------------------------------------------------------
|
| Used by workkit:db:backup, workkit:db:restore and
| workkit:db:prune-backups. The default `path` is storage_path('backups')
| so backups live alongside the rest of the app's storage. `retention_days`
| is the threshold workkit:db:prune-backups uses by default anything
| older than that gets deleted on the next prune run.
|
*/
'backup' => [
'path' => env('WORKKIT_BACKUP_PATH'), // null → storage_path('backups')
'retention_days' => (int) env('WORKKIT_BACKUP_RETENTION_DAYS', 30),
],
];

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Commands\Database;
use Blax\Workkit\Services\BackupService;
use Illuminate\Console\Command;
use RuntimeException;
/**
* Dump the configured MySQL database to a compressed + APP_KEY-encrypted
* file under storage/backups. Output filename is
* db_<connection>_<timestamp>.sql.xz.enc
*
* The DB password is passed to mysqldump via the MYSQL_PWD env var, not
* on the CLI process listings (`ps auxf`) don't expose it that way.
*/
class BackupCommand extends Command
{
protected $signature = 'workkit:db:backup
{--connection= : DB connection to back up (defaults to config(database.default))}
{--out= : Custom output path (overrides storage/backups default)}';
protected $description = 'Create a compressed + encrypted backup of the configured MySQL database.';
public function handle(): int
{
$connection = $this->option('connection') ?: config('database.default');
$cfg = config("database.connections.{$connection}");
if (! $cfg) {
$this->error("Unknown database connection: {$connection}");
return self::FAILURE;
}
if (($cfg['driver'] ?? null) !== 'mysql') {
$this->error("workkit:db:backup currently supports only MySQL connections (got: {$cfg['driver']}).");
return self::FAILURE;
}
try {
BackupService::requireBinary('mysqldump');
} catch (RuntimeException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$stamp = date('Y-m-d_H-i-s');
$base = BackupService::backupDirectory();
$sqlPath = $this->option('out')
?: "{$base}/db_{$connection}_{$stamp}.sql";
$this->info("Dumping `{$cfg['database']}` from {$cfg['host']}{$sqlPath}");
try {
$this->dump($cfg, $sqlPath);
$this->info('Compressing with xz…');
$xzPath = BackupService::compressFile($sqlPath);
@unlink($sqlPath);
$this->info('Encrypting with APP_KEY…');
$encPath = BackupService::encryptFile($xzPath);
@unlink($xzPath);
} catch (RuntimeException $e) {
// Clean up any half-written intermediate files so a failed
// backup doesn't leave SQL with secrets sitting unencrypted
// on disk.
foreach ([$sqlPath, $sqlPath . '.xz'] as $leftover) {
if (is_file($leftover)) {
@unlink($leftover);
}
}
$this->error($e->getMessage());
return self::FAILURE;
}
$size = filesize($encPath);
$this->info(sprintf('Backup complete: %s (%s)', $encPath, self::humanBytes((int) $size)));
return self::SUCCESS;
}
/**
* Run mysqldump with credentials passed via env vars (MYSQL_PWD) so
* the password never appears in the process listing or shell history.
*/
private function dump(array $cfg, string $sqlPath): void
{
$args = [
'--single-transaction', // consistent dump on InnoDB without FLUSH TABLES locking
'--quick', // stream rows instead of buffering
'--skip-lock-tables',
'--user=' . escapeshellarg((string) ($cfg['username'] ?? 'root')),
'--host=' . escapeshellarg((string) ($cfg['host'] ?? 'localhost')),
'--port=' . escapeshellarg((string) ($cfg['port'] ?? 3306)),
escapeshellarg((string) $cfg['database']),
];
$cmd = 'mysqldump ' . implode(' ', $args) . ' > ' . escapeshellarg($sqlPath);
BackupService::run($cmd, [
'MYSQL_PWD' => (string) ($cfg['password'] ?? ''),
]);
if (! file_exists($sqlPath) || filesize($sqlPath) === 0) {
throw new RuntimeException("mysqldump produced no output at {$sqlPath}");
}
}
private static function humanBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return sprintf('%.2f %s', $bytes, $units[$i]);
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Commands\Database;
use Blax\Workkit\Services\BackupService;
use Illuminate\Console\Command;
/**
* Drop backup files older than the retention window. Defaults to 30
* days; the host can override via --days or by setting
* `workkit.backup.retention_days` in the published config.
*
* Designed to be wired into the scheduler so storage doesn't fill up
* with stale dumps, e.g. Schedule::command('workkit:db:prune-backups')->daily();
*/
class PruneBackupsCommand extends Command
{
protected $signature = 'workkit:db:prune-backups
{--days= : Retention window in days (default: workkit.backup.retention_days, falling back to 30)}
{--dry-run : Show what would be deleted without removing anything}';
protected $description = 'Delete backup files older than the retention window.';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('workkit.backup.retention_days', 30));
if ($days < 1) {
$this->error('--days must be >= 1');
return self::FAILURE;
}
$base = BackupService::backupDirectory();
$files = glob($base . '/*') ?: [];
$cutoff = time() - $days * 86400;
$removed = 0;
$kept = 0;
foreach ($files as $f) {
if (! is_file($f)) {
continue;
}
if (filemtime($f) < $cutoff) {
if ($this->option('dry-run')) {
$this->line("would remove: {$f}");
} else {
@unlink($f);
$this->line("removed: {$f}");
}
$removed++;
} else {
$kept++;
}
}
$this->info(sprintf(
'Backups: %d kept, %d %s (cutoff: %d days).',
$kept,
$removed,
$this->option('dry-run') ? 'would-remove' : 'removed',
$days,
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Commands\Database;
use Blax\Workkit\Services\BackupService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Encryption\DecryptException;
use RuntimeException;
/**
* Restore a backup produced by workkit:db:backup. Without --file, picks
* the newest backup in storage/backups by mtime. Detects .enc and .xz
* suffixes to decide which decode steps to run, so this also works
* with un-encrypted or un-compressed dumps if the operator restored
* a hand-prepared file.
*
* Refuses to run in production unless --force is passed: a restore is
* a destructive operation that overwrites the live database.
*/
class RestoreCommand extends Command
{
protected $signature = 'workkit:db:restore
{--connection= : DB connection to restore into (defaults to config(database.default))}
{--file= : Specific backup filename inside the backups directory (default: newest by mtime)}
{--force : Skip the confirmation prompt}';
protected $description = 'Restore a (compressed + encrypted) database backup. Defaults to the newest file in storage/backups.';
public function handle(): int
{
$connection = $this->option('connection') ?: config('database.default');
$cfg = config("database.connections.{$connection}");
if (! $cfg) {
$this->error("Unknown database connection: {$connection}");
return self::FAILURE;
}
if (($cfg['driver'] ?? null) !== 'mysql') {
$this->error("workkit:db:restore currently supports only MySQL connections (got: {$cfg['driver']}).");
return self::FAILURE;
}
try {
BackupService::requireBinary('mysql');
} catch (RuntimeException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$file = $this->resolveFile();
if (! $file) {
$this->error('No backup found.');
return self::FAILURE;
}
if (! file_exists($file)) {
$this->error("Backup file not found: {$file}");
return self::FAILURE;
}
$this->warn(sprintf(
'About to restore `%s`@%s from: %s',
$cfg['database'],
$cfg['host'],
$file,
));
$this->warn('This will OVERWRITE any data that conflicts with the dump.');
if (! $this->option('force') && ! $this->confirm('Proceed?', false)) {
$this->info('Aborted.');
return self::SUCCESS;
}
$tmpSql = null;
try {
$tmpSql = $this->prepare($file);
$this->info("Restoring → {$cfg['database']}");
$this->importInto($cfg, $tmpSql);
$this->info('Restore complete.');
return self::SUCCESS;
} catch (DecryptException $e) {
// Almost always means the file was encrypted with a different
// APP_KEY than the current one. Spell that out so the operator
// doesn't have to recognise the cryptographer's stack trace.
$this->error('Decryption failed — likely an APP_KEY mismatch between backup and current environment.');
$this->line("Underlying error: {$e->getMessage()}");
return self::FAILURE;
} catch (RuntimeException $e) {
$this->error($e->getMessage());
return self::FAILURE;
} finally {
if ($tmpSql && is_file($tmpSql)) {
@unlink($tmpSql);
}
}
}
/**
* Resolve the file argument:
* - --file=path/to/X.sql.xz.enc use as-is if it exists
* - --file=basename look up inside storage/backups
* - omitted pick newest in storage/backups
*/
private function resolveFile(): ?string
{
$base = BackupService::backupDirectory();
$opt = $this->option('file');
if ($opt) {
if (is_file($opt)) {
return $opt;
}
$candidate = $base . '/' . ltrim((string) $opt, '/');
return is_file($candidate) ? $candidate : null;
}
$files = glob($base . '/*');
if (! $files) {
return null;
}
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
return $files[0];
}
/**
* Walk the file through decrypt + decompress as needed and write the
* resulting SQL to a temp path. Returns the path of the .sql file.
*/
private function prepare(string $file): string
{
$current = $file;
$intermediate = [];
if (str_ends_with($current, '.enc')) {
$this->info('Decrypting…');
$current = BackupService::decryptFile($current, sys_get_temp_dir() . '/workkit_restore_' . uniqid() . '.dec');
$intermediate[] = $current;
}
if (str_ends_with($current, '.xz')) {
$this->info('Decompressing…');
$next = BackupService::decompressFile($current, sys_get_temp_dir() . '/workkit_restore_' . uniqid() . '.sql');
// Drop the .xz intermediate now that we have the .sql.
foreach ($intermediate as $f) {
if (is_file($f)) {
@unlink($f);
}
}
$intermediate = [];
$current = $next;
$intermediate[] = $current;
}
// Sanity check: a real SQL dump always has at least one
// CREATE/INSERT/USE statement near the top. Catches the case
// where APP_KEY didn't match (Crypt::decryptString throws on
// bad key, but if someone hand-renamed a non-encrypted file to
// .enc this still helps).
$head = (string) @file_get_contents($current, false, null, 0, 8192);
if (! preg_match('/(INSERT\s+INTO|CREATE\s+TABLE|USE\s+`)/i', $head)) {
throw new RuntimeException('Decoded file does not look like a SQL dump (no CREATE/INSERT/USE found in header).');
}
return $current;
}
/**
* Pipe the .sql file into the mysql CLI, password via MYSQL_PWD.
*/
private function importInto(array $cfg, string $sqlPath): void
{
$args = [
'--user=' . escapeshellarg((string) ($cfg['username'] ?? 'root')),
'--host=' . escapeshellarg((string) ($cfg['host'] ?? 'localhost')),
'--port=' . escapeshellarg((string) ($cfg['port'] ?? 3306)),
escapeshellarg((string) $cfg['database']),
];
$cmd = 'mysql ' . implode(' ', $args) . ' < ' . escapeshellarg($sqlPath);
BackupService::run($cmd, [
'MYSQL_PWD' => (string) ($cfg['password'] ?? ''),
]);
}
}

View File

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Services;
use Illuminate\Support\Facades\Crypt;
use RuntimeException;
/**
* Compression + encryption primitives shared by the backup/restore
* commands. Encryption uses Laravel's Crypt facade, so the AES key is
* derived from APP_KEY same key that decrypts the rest of the app's
* encrypted columns/cookies. That means a backup is restorable only
* by a deployment that knows the host's APP_KEY.
*
* Compression goes through the system `xz` binary because it gives by
* far the best ratio for repetitive SQL dump text and is cheap to
* stream. Hosts without `xz` installed get a clear failure rather
* than silently falling back to a worse codec.
*/
class BackupService
{
/**
* Compress $in with `xz` and return the path of the .xz output.
* Defaults to writing alongside the input.
*/
public static function compressFile(string $in, ?string $out = null): string
{
$out ??= $in . '.xz';
self::requireBinary('xz');
// -9 for max ratio, -T0 for parallel encoding on whatever cores
// the host has. -c emits to stdout so we don't clobber $in in
// place — the caller decides when to delete it.
self::run(sprintf('xz -z -9 -T0 -c %s > %s', escapeshellarg($in), escapeshellarg($out)));
if (! file_exists($out)) {
throw new RuntimeException("xz compression failed; output not found at {$out}");
}
return $out;
}
/**
* Decompress an .xz file. If $out is null, strips the `.xz` suffix
* (or generates a uniquely-named sibling if there is none).
*/
public static function decompressFile(string $in, ?string $out = null): string
{
self::requireBinary('xz');
if ($out === null) {
$out = str_ends_with($in, '.xz')
? substr($in, 0, -3)
: $in . '.decompressed';
}
self::run(sprintf('xz -d -T0 -c %s > %s', escapeshellarg($in), escapeshellarg($out)));
if (! file_exists($out)) {
throw new RuntimeException("xz decompression failed; output not found at {$out}");
}
return $out;
}
/**
* Encrypt $in with Crypt:: (APP_KEY-derived) and return the path
* of the .enc output.
*/
public static function encryptFile(string $in, ?string $out = null): string
{
$out ??= $in . '.enc';
$payload = Crypt::encryptString(file_get_contents($in));
file_put_contents($out, $payload);
return $out;
}
/**
* Decrypt $in (Crypt:: payload) and write to $out. If $out is null,
* strips the `.enc` suffix.
*/
public static function decryptFile(string $in, ?string $out = null): string
{
if ($out === null) {
$out = str_ends_with($in, '.enc')
? substr($in, 0, -4)
: $in . '.decrypted';
}
$payload = file_get_contents($in);
file_put_contents($out, Crypt::decryptString($payload));
return $out;
}
/**
* Path of the host's backup directory, created if missing.
* Defaults to storage/backups; the host can override via the
* `workkit.backup.path` config (published by the package).
*/
public static function backupDirectory(): string
{
$path = config('workkit.backup.path') ?: storage_path('backups');
if (! is_dir($path)) {
mkdir($path, 0755, true);
}
return rtrim($path, '/');
}
/**
* Bail loudly if a required system binary isn't on $PATH. We do
* this early in each command so users get one clear message
* instead of a cryptic exec failure halfway through.
*/
public static function requireBinary(string $bin): void
{
$found = trim((string) @shell_exec('command -v ' . escapeshellarg($bin)));
if ($found === '') {
throw new RuntimeException("Required binary `{$bin}` not found on PATH.");
}
}
/**
* Run a shell command and throw on non-zero exit.
*/
public static function run(string $command, array $env = []): void
{
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$envForProc = $env === [] ? null : array_merge($_ENV, $env);
$proc = proc_open($command, $descriptors, $pipes, null, $envForProc);
if (! is_resource($proc)) {
throw new RuntimeException("Failed to start process: {$command}");
}
fclose($pipes[0]);
// We don't need stdout — most of these commands write to files.
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exit = proc_close($proc);
if ($exit !== 0) {
throw new RuntimeException(sprintf(
"Command failed (exit %d): %s\nstderr:\n%s",
$exit,
self::redactCommand($command),
trim((string) $stderr) ?: '(empty)'
));
}
}
/**
* Hide credential-looking flags in error messages so we don't dump
* passwords to logs. Crude but enough for the legacy CLI flag form;
* the commands themselves prefer MYSQL_PWD env vars.
*/
private static function redactCommand(string $command): string
{
return preg_replace('/(--password=)[^\s]+/', '$1***', $command);
}
}

View File

@ -2,6 +2,9 @@
namespace Blax\Workkit;
use Blax\Workkit\Commands\Database\BackupCommand;
use Blax\Workkit\Commands\Database\PruneBackupsCommand;
use Blax\Workkit\Commands\Database\RestoreCommand;
use Blax\Workkit\Commands\PlugNPrayCommand;
class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
@ -13,7 +16,7 @@ class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
*/
public function register()
{
//
$this->mergeConfigFrom(__DIR__ . '/../config/workkit.php', 'workkit');
}
/**
@ -26,7 +29,16 @@ class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
if ($this->app->runningInConsole()) {
$this->commands([
PlugNPrayCommand::class,
BackupCommand::class,
RestoreCommand::class,
PruneBackupsCommand::class,
]);
// Hosts that want to override path / retention publish the
// config; otherwise mergeConfigFrom() above provides defaults.
$this->publishes([
__DIR__ . '/../config/workkit.php' => $this->app->configPath('workkit.php'),
], 'workkit-config');
}
}
}