Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions config/database-backups.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

/*
* Environment defaults for database backups.
*
* A system administrator overrides any of these from the console (Admin → Database Backups);
* the override is stored as the `system.database-backups` setting. See
* Fleetbase\Support\DatabaseBackupSettings.
*/
return [
/*
* Whether scheduled backups run at all. Off by default so a fresh install does not
* start dumping its database somewhere before anyone has chosen where.
*/
'enabled' => env('DB_BACKUP_ENABLED', false),

/*
* hourly, every_six_hours, every_twelve_hours, daily or weekly. Times are UTC.
*/
'frequency' => env('DB_BACKUP_FREQUENCY', 'daily'),
'time' => env('DB_BACKUP_TIME', '00:00'),
'day_of_week' => (int) env('DB_BACKUP_DAY_OF_WEEK', 0),

/*
* Where dumps go: a disk from config/filesystems.php, an optional bucket override for
* s3 disks, and a key prefix.
*/
'disk' => env('DB_BACKUP_DISK', 's3'),
'bucket' => env('DB_BACKUP_BUCKET', 'fleetbase-db-backups'),
'path' => env('DB_BACKUP_PATH', ''),

/*
* The database connections to dump, by name.
*/
'connections' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_CONNECTIONS', 'mysql,sandbox'))))),

/*
* Retention, applied after each fully successful run. Null disables that limit.
*/
'retention_days' => env('DB_BACKUP_RETENTION_DAYS', 30),
'retention_count' => env('DB_BACKUP_RETENTION_COUNT'),

/*
* A compressed dump smaller than this many bytes fails the run. A gzip of nothing is
* 20 bytes; even an empty schema dump compresses to several hundred.
*/
'min_size_bytes' => (int) env('DB_BACKUP_MIN_SIZE_BYTES', 1024),

/*
* Who hears about a failed run.
*/
'notify_on_failure' => env('DB_BACKUP_NOTIFY_ON_FAILURE', false),
'notify_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_NOTIFY_EMAILS', ''))))),

/*
* The dump client and the arguments it always gets. --single-transaction gives a
* consistent InnoDB snapshot without locking; --no-tablespaces avoids needing the
* PROCESS privilege, which managed databases such as RDS do not grant.
*/
'dump_binary' => env('DB_BACKUP_DUMP_BINARY', 'mysqldump'),
'dump_args' => [
'--single-transaction',
'--quick',
'--routines',
'--triggers',
'--hex-blob',
'--no-tablespaces',
'--default-character-set=utf8mb4',
],
'extra_dump_args' => array_values(array_filter(explode(' ', (string) env('DB_BACKUP_EXTRA_DUMP_ARGS', '')))),

/*
* Seconds a single database's dump may take.
*/
'timeout' => (int) env('DB_BACKUP_TIMEOUT', 7200),

/*
* Where dumps are written before upload.
*/
'tmp_dir' => env('DB_BACKUP_TMP_DIR', sys_get_temp_dir()),
];
63 changes: 0 additions & 63 deletions config/laravel-mysql-s3-backup.php

This file was deleted.

44 changes: 44 additions & 0 deletions migrations/2026_10_06_000000_create_database_backups_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* One row per database per backup run, so an administrator can see what ran, how big it
* was, how long it took and why it failed — the old command wrote nothing anywhere and
* reported success while uploading empty dumps.
*/
public function up(): void
{
Schema::create('database_backups', function (Blueprint $table) {
$table->uuid('uuid')->primary();
$table->string('connection_name', 64);
$table->string('database', 128);
$table->string('status', 16)->index();
$table->string('trigger', 16);
$table->string('disk', 64);
$table->string('path', 512)->nullable();
$table->unsignedBigInteger('size_bytes')->nullable();
$table->unsignedBigInteger('duration_ms')->nullable();
$table->text('error')->nullable();
$table->timestamp('started_at')->index();
$table->timestamp('completed_at')->nullable();
$table->timestamp('pruned_at')->nullable();
$table->timestamps();

$table->index(['disk', 'path']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('database_backups');
}
};
65 changes: 65 additions & 0 deletions src/Console/Commands/BackupDatabase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Fleetbase\Console\Commands;

use Fleetbase\Models\DatabaseBackup;
use Fleetbase\Services\DatabaseBackup\DatabaseBackupException;
use Fleetbase\Services\DatabaseBackup\DatabaseBackupService;
use Fleetbase\Support\DatabaseBackupSettings;
use Illuminate\Console\Command;

/**
* Dump the configured databases and upload them to the backup disk.
*
* Exits non-zero when any database fails, so the scheduler reports FAIL instead of DONE.
*/
class BackupDatabase extends Command
{
protected $signature = 'db:backup
{--connection=* : Back up only these connections (default: those in the backup settings)}
{--trigger=console : Recorded as what started the run (scheduled, manual or console)}
{--force : Run even when backups are disabled in the settings}';

protected $description = 'Dump the MySQL databases, upload them to the backup disk and apply retention';

public function handle(DatabaseBackupService $service): int
{
$settings = DatabaseBackupSettings::settings();

if (!$settings['enabled'] && !$this->option('force')) {
$this->info('Database backups are disabled. Enable them under Admin → Database Backups, or pass --force.');

return self::SUCCESS;
}

$trigger = in_array($this->option('trigger'), [DatabaseBackup::TRIGGER_SCHEDULED, DatabaseBackup::TRIGGER_MANUAL, DatabaseBackup::TRIGGER_CONSOLE], true)
? $this->option('trigger')
: DatabaseBackup::TRIGGER_CONSOLE;

try {
$records = $service->run($trigger, $this->option('connection') ?: null, $settings);
} catch (DatabaseBackupException $e) {
$this->error($e->getMessage());

return self::FAILURE;
}

if (!$records) {
$this->error('No database connections are configured for backup.');

return self::FAILURE;
}

$failed = 0;
foreach ($records as $record) {
if ($record->status === DatabaseBackup::STATUS_COMPLETED) {
$this->info(sprintf('Backed up %s to %s:%s (%d bytes, %d ms)', $record->database, $record->disk, $record->path, $record->size_bytes, $record->duration_ms));
} else {
$failed++;
$this->error(sprintf('Backup of %s failed: %s', $record->database, $record->error));
}
}

return $failed ? self::FAILURE : self::SUCCESS;
}
}
Loading
Loading