This commit is contained in:
🏔️ Peak 2026-02-25 12:42:10 +01:00 committed by GitHub
commit 13e8ef02b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 259 additions and 596 deletions

View file

@ -54,7 +54,6 @@ class Emails extends Command
options: [
'updates' => 'Send Update Email to all users',
'emails-test' => 'Test',
'database-backup-statuses-daily' => 'Database - Backup Statuses (Daily)',
'application-deployment-success-daily' => 'Application - Deployment Success (Daily)',
'application-deployment-success' => 'Application - Deployment Success',
'application-deployment-failed' => 'Application - Deployment Failed',
@ -167,7 +166,7 @@ class Emails extends Command
]);
}
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
$this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail();
$this->mail = (new BackupFailed($backup, $db, $output))->toMail();
$this->sendEmail();
break;
case 'backup-success':

View file

@ -627,8 +627,6 @@ class DatabasesController extends Controller
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true],
'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false],
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'],
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false],
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
@ -672,7 +670,7 @@ class DatabasesController extends Controller
)]
public function create_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
$backupConfigFields = ['save_s3', 'enabled', 'frequency', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -689,10 +687,8 @@ class DatabasesController extends Controller
'frequency' => 'required|string',
'enabled' => 'boolean',
'save_s3' => 'boolean',
'dump_all' => 'boolean',
'backup_now' => 'boolean|nullable',
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
@ -777,17 +773,6 @@ class DatabasesController extends Controller
unset($backupData['s3_storage_uuid']);
}
// Set default databases_to_backup based on database type if not provided
if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) {
if ($database->type() === 'standalone-postgresql') {
$backupData['databases_to_backup'] = $database->postgres_db;
} elseif ($database->type() === 'standalone-mysql') {
$backupData['databases_to_backup'] = $database->mysql_database;
} elseif ($database->type() === 'standalone-mariadb') {
$backupData['databases_to_backup'] = $database->mariadb_database;
}
}
// Add required fields
$backupData['database_id'] = $database->id;
$backupData['database_type'] = $database->getMorphClass();
@ -852,8 +837,6 @@ class DatabasesController extends Controller
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'],
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'],
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'],
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'],
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
@ -890,7 +873,7 @@ class DatabasesController extends Controller
)]
public function update_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
$backupConfigFields = ['save_s3', 'enabled', 'frequency', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -905,9 +888,7 @@ class DatabasesController extends Controller
'save_s3' => 'boolean',
'backup_now' => 'boolean|nullable',
'enabled' => 'boolean',
'dump_all' => 'boolean',
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
'databases_to_backup' => 'string|nullable',
'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',

View file

@ -83,8 +83,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
try {
$databasesToBackup = null;
$this->team = Team::find($this->backup->team_id);
if (! $this->team) {
$this->backup->delete();
@ -133,15 +131,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->database->postgres_user = 'postgres';
}
$db = $envs->filter(function ($env) {
return str($env)->startsWith('POSTGRES_DB=');
})->first();
if ($db) {
$databasesToBackup = str($db)->after('POSTGRES_DB=')->value();
} else {
$databasesToBackup = $this->database->postgres_user;
}
$this->postgres_password = $envs->filter(function ($env) {
return str($env)->startsWith('POSTGRES_PASSWORD=');
})->first();
@ -161,16 +150,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($rootPassword) {
$this->database->mysql_root_password = str($rootPassword)->after('MYSQL_ROOT_PASSWORD=')->value();
}
$db = $envs->filter(function ($env) {
return str($env)->startsWith('MYSQL_DATABASE=');
})->first();
if ($db) {
$databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value();
} else {
throw new \Exception('MYSQL_DATABASE not found');
}
} elseif (str($databaseType)->contains('mariadb')) {
$this->container_name = "{$this->database->name}-$serviceUuid";
$this->directory_name = $serviceName.'-'.$this->container_name;
@ -190,26 +169,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->database->mariadb_root_password = str($rootPassword)->after('MYSQL_ROOT_PASSWORD=')->value();
}
}
$db = $envs->filter(function ($env) {
return str($env)->startsWith('MARIADB_DATABASE=');
})->first();
if ($db) {
$databasesToBackup = str($db)->after('MARIADB_DATABASE=')->value();
} else {
$db = $envs->filter(function ($env) {
return str($env)->startsWith('MYSQL_DATABASE=');
})->first();
if ($db) {
$databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value();
} else {
throw new \Exception('MARIADB_DATABASE or MYSQL_DATABASE not found');
}
}
} elseif (str($databaseType)->contains('mongo')) {
$databasesToBackup = ['*'];
$this->container_name = "{$this->database->name}-$serviceUuid";
$this->directory_name = $serviceName.'-'.$this->container_name;
@ -244,44 +204,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->container_name = $this->database->uuid;
$this->directory_name = $databaseName.'-'.$this->container_name;
$databaseType = $this->database->type();
$databasesToBackup = data_get($this->backup, 'databases_to_backup');
}
if (blank($databasesToBackup)) {
if (str($databaseType)->contains('postgres')) {
$databasesToBackup = [$this->database->postgres_db];
} elseif (str($databaseType)->contains('mongo')) {
$databasesToBackup = ['*'];
} elseif (str($databaseType)->contains('mysql')) {
$databasesToBackup = [$this->database->mysql_database];
} elseif (str($databaseType)->contains('mariadb')) {
$databasesToBackup = [$this->database->mariadb_database];
} else {
return;
}
} else {
if (str($databaseType)->contains('postgres')) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif (str($databaseType)->contains('mongo')) {
// Format: db1:collection1,collection2|db2:collection3,collection4
// Only explode if it's a string, not if it's already an array
if (is_string($databasesToBackup)) {
$databasesToBackup = explode('|', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
}
} elseif (str($databaseType)->contains('mysql')) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif (str($databaseType)->contains('mariadb')) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} else {
return;
}
}
$this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name;
if ($this->database->name === 'coolify-db') {
$databasesToBackup = ['coolify'];
@ -289,156 +213,131 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$ip = Str::slug($this->server->ip);
$this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip";
}
foreach ($databasesToBackup as $database) {
// Generate unique UUID for each database backup execution
$attempts = 0;
do {
$this->backup_log_uuid = (string) new Cuid2;
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts');
}
} while ($exists);
$size = 0;
$localBackupSucceeded = false;
$s3UploadError = null;
// Generate unique UUID for backup execution
$attempts = 0;
do {
$this->backup_log_uuid = (string) new Cuid2;
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts');
}
} while ($exists);
// Step 1: Create local backup
$size = 0;
$localBackupSucceeded = false;
$s3UploadError = null;
// Step 1: Create local backup (always dump all databases)
try {
if (str($databaseType)->contains('postgres')) {
$this->backup_file = '/pg-dump-all-'.Carbon::now()->timestamp.'.sql.gz';
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_postgresql();
} elseif (str($databaseType)->contains('mongo')) {
$this->backup_file = '/mongo-dump-all-'.Carbon::now()->timestamp.'.tar.gz';
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mongodb();
} elseif (str($databaseType)->contains('mysql')) {
$this->backup_file = '/mysql-dump-all-'.Carbon::now()->timestamp.'.sql.gz';
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mysql();
} elseif (str($databaseType)->contains('mariadb')) {
$this->backup_file = '/mariadb-dump-all-'.Carbon::now()->timestamp.'.sql.gz';
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mariadb();
} else {
throw new \Exception('Unsupported database type');
}
$size = $this->calculate_size();
// Verify local backup succeeded
if ($size > 0) {
$localBackupSucceeded = true;
} else {
throw new \Exception('Local backup file is empty or was not created');
}
} catch (\Throwable $e) {
// Local backup failed
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
's3_uploaded' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage()));
throw $e;
}
// Step 2: Upload to S3 if enabled (independent of local backup)
$localStorageDeleted = false;
if ($this->backup->save_s3 && $localBackupSucceeded) {
try {
if (str($databaseType)->contains('postgres')) {
$this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp';
if ($this->backup->dump_all) {
$this->backup_file = '/pg-dump-all-'.Carbon::now()->timestamp.'.gz';
}
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongo')) {
if ($database === '*') {
$database = 'all';
$databaseName = 'all';
} else {
if (str($database)->contains(':')) {
$databaseName = str($database)->before(':');
} else {
$databaseName = $database;
}
}
$this->backup_file = "/mongo-dump-$databaseName-".Carbon::now()->timestamp.'.tar.gz';
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $databaseName,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mongodb($database);
} elseif (str($databaseType)->contains('mysql')) {
$this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp';
if ($this->backup->dump_all) {
$this->backup_file = '/mysql-dump-all-'.Carbon::now()->timestamp.'.gz';
}
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mysql($database);
} elseif (str($databaseType)->contains('mariadb')) {
$this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp';
if ($this->backup->dump_all) {
$this->backup_file = '/mariadb-dump-all-'.Carbon::now()->timestamp.'.gz';
}
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mariadb($database);
} else {
throw new \Exception('Unsupported database type');
}
$this->upload_to_s3();
$size = $this->calculate_size();
// Verify local backup succeeded
if ($size > 0) {
$localBackupSucceeded = true;
} else {
throw new \Exception('Local backup file is empty or was not created');
// If local backup is disabled, delete the local file immediately after S3 upload
if ($this->backup->disable_local_backup) {
deleteBackupsLocally($this->backup_location, $this->server);
$localStorageDeleted = true;
}
} catch (\Throwable $e) {
// Local backup failed
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
's3_uploaded' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
}
}
continue;
// Step 3: Update status and send notifications based on results
if ($localBackupSucceeded) {
$message = $this->backup_output;
if ($s3UploadError) {
$message = $message
? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
: 'Warning: S3 upload failed: '.$s3UploadError;
}
// Step 2: Upload to S3 if enabled (independent of local backup)
$localStorageDeleted = false;
if ($this->backup->save_s3 && $localBackupSucceeded) {
try {
$this->upload_to_s3();
$this->backup_log->update([
'status' => 'success',
'message' => $message,
'size' => $size,
's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
'local_storage_deleted' => $localStorageDeleted,
]);
// If local backup is disabled, delete the local file immediately after S3 upload
if ($this->backup->disable_local_backup) {
deleteBackupsLocally($this->backup_location, $this->server);
$localStorageDeleted = true;
}
} catch (\Throwable $e) {
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
}
}
// Step 3: Update status and send notifications based on results
if ($localBackupSucceeded) {
$message = $this->backup_output;
if ($s3UploadError) {
$message = $message
? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
: 'Warning: S3 upload failed: '.$s3UploadError;
}
$this->backup_log->update([
'status' => 'success',
'message' => $message,
'size' => $size,
's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
'local_storage_deleted' => $localStorageDeleted,
]);
// Send appropriate notification
if ($s3UploadError) {
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
} else {
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
}
// Send appropriate notification
if ($s3UploadError) {
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $s3UploadError));
} else {
$this->team->notify(new BackupSuccess($this->backup, $this->database));
}
}
if ($this->backup_log && $this->backup_log->status === 'success') {
@ -458,7 +357,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function backup_standalone_mongodb(string $databaseWithCollections): void
private function backup_standalone_mongodb(): void
{
try {
$url = $this->database->internal_db_url;
@ -473,41 +372,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
\Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
if ($databaseWithCollections === 'all') {
$commands[] = 'mkdir -p '.$this->backup_dir;
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location";
}
$commands[] = 'mkdir -p '.$this->backup_dir;
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
} else {
if (str($databaseWithCollections)->contains(':')) {
$databaseName = str($databaseWithCollections)->before(':');
$collectionsToExclude = str($databaseWithCollections)->after(':')->explode(',');
} else {
$databaseName = $databaseWithCollections;
$collectionsToExclude = collect();
}
$commands[] = 'mkdir -p '.$this->backup_dir;
// Validate and escape database name to prevent command injection
validateShellSafePath($databaseName, 'database name');
$escapedDatabaseName = escapeshellarg($databaseName);
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
@ -519,7 +391,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function backup_standalone_postgresql(string $database): void
private function backup_standalone_postgresql(): void
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
@ -527,14 +399,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->postgres_password) {
$backupCommand .= " -e PGPASSWORD=\"{$this->postgres_password}\"";
}
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
}
$backupCommand .= " $this->container_name pg_dumpall --clean --if-exists --username {$this->database->postgres_user} | gzip > $this->backup_location";
$commands[] = $backupCommand;
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
@ -548,18 +413,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function backup_standalone_mysql(string $database): void
private function backup_standalone_mysql(): void
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
}
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false | gzip > $this->backup_location";
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
@ -571,18 +429,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function backup_standalone_mariadb(string $database): void
private function backup_standalone_mariadb(): void
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
}
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false | gzip > $this->backup_location";
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
@ -709,9 +560,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
// Notify team about permanent failure
if ($this->team) {
$databaseName = $log?->database_name ?? 'unknown';
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
$this->team->notify(new BackupFailed($this->backup, $this->database, $output));
}
}
}

View file

@ -69,12 +69,6 @@ class BackupEdit extends Component
#[Validate(['nullable', 'integer'])]
public ?int $s3StorageId = 1;
#[Validate(['nullable', 'string'])]
public ?string $databasesToBackup = null;
#[Validate(['required', 'boolean'])]
public bool $dumpAll = false;
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
public int $timeout = 3600;
@ -103,27 +97,6 @@ class BackupEdit extends Component
$this->backup->save_s3 = $this->saveS3;
$this->backup->disable_local_backup = $this->disableLocalBackup;
$this->backup->s3_storage_id = $this->s3StorageId;
// Validate databases_to_backup to prevent command injection
if (filled($this->databasesToBackup)) {
$databases = str($this->databasesToBackup)->explode(',');
foreach ($databases as $index => $db) {
$dbName = trim($db);
try {
validateShellSafePath($dbName, 'database name');
} catch (\Exception $e) {
// Provide specific error message indicating which database failed validation
$position = $index + 1;
throw new \Exception(
"Database #{$position} ('{$dbName}') validation failed: ".
$e->getMessage()
);
}
}
}
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;
$this->customValidate();
$this->backup->save();
@ -140,8 +113,6 @@ class BackupEdit extends Component
$this->saveS3 = $this->backup->save_s3;
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
$this->s3StorageId = $this->backup->s3_storage_id;
$this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout;
}
}

View file

@ -65,14 +65,6 @@ class CreateScheduledBackup extends Component
'team_id' => currentTeam()->id,
];
if ($this->database->type() === 'standalone-postgresql') {
$payload['databases_to_backup'] = $this->database->postgres_db;
} elseif ($this->database->type() === 'standalone-mysql') {
$payload['databases_to_backup'] = $this->database->mysql_database;
} elseif ($this->database->type() === 'standalone-mariadb') {
$payload['databases_to_backup'] = $this->database->mariadb_database;
}
$databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);

View file

@ -139,7 +139,9 @@ class Import extends Component
public array $importCommands = [];
public bool $dumpAll = false;
public bool $legacyDumpAll = false;
public bool $legacySingleDb = false;
public string $restoreCommandText = '';
@ -147,11 +149,11 @@ class Import extends Component
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $postgresqlRestoreCommand = 'gunzip -c $tmpPath | psql -X -U ${POSTGRES_USER} -d postgres';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
public string $mysqlRestoreCommand = 'gunzip -c $tmpPath | mysql -u root -p$MYSQL_ROOT_PASSWORD';
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
public string $mariadbRestoreCommand = 'gunzip -c $tmpPath | mariadb -u root -p$MARIADB_ROOT_PASSWORD';
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
@ -206,7 +208,23 @@ class Import extends Component
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
public function updatedLegacyDumpAll($value)
{
if ($value) {
$this->legacySingleDb = false;
}
$this->updateRestoreCommands();
}
public function updatedLegacySingleDb($value)
{
if ($value) {
$this->legacyDumpAll = false;
}
$this->updateRestoreCommands();
}
private function updateRestoreCommands()
{
$morphClass = $this->resource->getMorphClass();
@ -225,7 +243,7 @@ class Import extends Component
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
if ($this->legacyDumpAll) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
@ -234,14 +252,15 @@ mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} elseif ($this->legacySingleDb) {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE < $tmpPath';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
$this->mariadbRestoreCommand = 'gunzip -c $tmpPath | mariadb -u root -p$MARIADB_ROOT_PASSWORD';
}
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
if ($value === true) {
if ($this->legacyDumpAll) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
@ -250,26 +269,29 @@ mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXIS
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} elseif ($this->legacySingleDb) {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < $tmpPath';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
$this->mysqlRestoreCommand = 'gunzip -c $tmpPath | mysql -u root -p$MYSQL_ROOT_PASSWORD';
}
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
if ($this->legacyDumpAll) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} elseif ($this->legacySingleDb) {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} $tmpPath';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
$this->postgresqlRestoreCommand = 'gunzip -c $tmpPath | psql -X -U ${POSTGRES_USER} -d postgres';
$this->restoreCommandText = 'Default: Restore from pg_dumpall file (gzipped SQL)';
}
break;
}
}
public function getContainers()
@ -737,37 +759,19 @@ EOD;
switch ($morphClass) {
case \App\Models\StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
$restoreCommand = str_replace('$tmpPath', $tmpPath, $this->mariadbRestoreCommand);
break;
case \App\Models\StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
$restoreCommand = str_replace('$tmpPath', $tmpPath, $this->mysqlRestoreCommand);
break;
case \App\Models\StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$tmpPath}";
}
$restoreCommand = str_replace('$tmpPath', $tmpPath, $this->postgresqlRestoreCommand);
break;
case \App\Models\StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
}
$restoreCommand = $this->mongodbRestoreCommand.$tmpPath;
break;
default:
$restoreCommand = '';

View file

@ -14,6 +14,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel
's3_uploaded' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',
'legacy' => 'boolean',
];
}

View file

@ -15,7 +15,7 @@ class BackupFailed extends CustomEmailNotification
public string $frequency;
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name)
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output)
{
$this->onQueue('high');
$this->name = $database->name;
@ -33,7 +33,6 @@ class BackupFailed extends CustomEmailNotification
$mail->subject("Coolify: [ACTION REQUIRED] Database Backup FAILED for {$this->database->name}");
$mail->view('emails.backup-failed', [
'name' => $this->name,
'database_name' => $this->database_name,
'frequency' => $this->frequency,
'output' => $this->output,
]);
@ -45,7 +44,7 @@ class BackupFailed extends CustomEmailNotification
{
$message = new DiscordMessage(
title: ':cross_mark: Database backup failed',
description: "Database backup for {$this->name} (db:{$this->database_name}) has FAILED.",
description: "Database backup for {$this->name} has FAILED.",
color: DiscordMessage::errorColor(),
isCritical: true,
);
@ -58,7 +57,7 @@ class BackupFailed extends CustomEmailNotification
public function toTelegram(): array
{
$message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} was FAILED.\n\nReason:\n{$this->output}";
$message = "Coolify: Database backup for {$this->name} with frequency of {$this->frequency} was FAILED.\n\nReason:\n{$this->output}";
return [
'message' => $message,
@ -70,14 +69,14 @@ class BackupFailed extends CustomEmailNotification
return new PushoverMessage(
title: 'Database backup failed',
level: 'error',
message: "Database backup for {$this->name} (db:{$this->database_name}) was FAILED<br/><br/><b>Frequency:</b> {$this->frequency} .<br/><b>Reason:</b> {$this->output}",
message: "Database backup for {$this->name} was FAILED<br/><br/><b>Frequency:</b> {$this->frequency} .<br/><b>Reason:</b> {$this->output}",
);
}
public function toSlack(): SlackMessage
{
$title = 'Database backup failed';
$description = "Database backup for {$this->name} (db:{$this->database_name}) has FAILED.";
$description = "Database backup for {$this->name} has FAILED.";
$description .= "\n\n*Frequency:* {$this->frequency}";
$description .= "\n\n*Error Output:* {$this->output}";
@ -99,7 +98,6 @@ class BackupFailed extends CustomEmailNotification
'event' => 'backup_failed',
'database_name' => $this->name,
'database_uuid' => $this->database->uuid,
'database_type' => $this->database_name,
'frequency' => $this->frequency,
'error_output' => $this->output,
'url' => $url,

View file

@ -15,7 +15,7 @@ class BackupSuccess extends CustomEmailNotification
public string $frequency;
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name)
public function __construct(ScheduledDatabaseBackup $backup, public $database)
{
$this->onQueue('high');
@ -34,7 +34,6 @@ class BackupSuccess extends CustomEmailNotification
$mail->subject("Coolify: Backup successfully done for {$this->database->name}");
$mail->view('emails.backup-success', [
'name' => $this->name,
'database_name' => $this->database_name,
'frequency' => $this->frequency,
]);
@ -45,7 +44,7 @@ class BackupSuccess extends CustomEmailNotification
{
$message = new DiscordMessage(
title: ':white_check_mark: Database backup successful',
description: "Database backup for {$this->name} (db:{$this->database_name}) was successful.",
description: "Database backup for {$this->name} was successful.",
color: DiscordMessage::successColor(),
);
@ -56,7 +55,7 @@ class BackupSuccess extends CustomEmailNotification
public function toTelegram(): array
{
$message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} was successful.";
$message = "Coolify: Database backup for {$this->name} with frequency of {$this->frequency} was successful.";
return [
'message' => $message,
@ -68,14 +67,14 @@ class BackupSuccess extends CustomEmailNotification
return new PushoverMessage(
title: 'Database backup successful',
level: 'success',
message: "Database backup for {$this->name} (db:{$this->database_name}) was successful.<br/><br/><b>Frequency:</b> {$this->frequency}.",
message: "Database backup for {$this->name} was successful.<br/><br/><b>Frequency:</b> {$this->frequency}.",
);
}
public function toSlack(): SlackMessage
{
$title = 'Database backup successful';
$description = "Database backup for {$this->name} (db:{$this->database_name}) was successful.";
$description = "Database backup for {$this->name} was successful.";
$description .= "\n\n*Frequency:* {$this->frequency}";
@ -96,7 +95,6 @@ class BackupSuccess extends CustomEmailNotification
'event' => 'backup_success',
'database_name' => $this->name,
'database_uuid' => $this->database->uuid,
'database_type' => $this->database_name,
'frequency' => $this->frequency,
'url' => $url,
];

View file

@ -17,7 +17,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification
public ?string $s3_storage_url = null;
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error)
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $s3_error)
{
$this->onQueue('high');
@ -40,7 +40,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification
$mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}");
$mail->view('emails.backup-success-with-s3-warning', [
'name' => $this->name,
'database_name' => $this->database_name,
'frequency' => $this->frequency,
's3_error' => $this->s3_error,
's3_storage_url' => $this->s3_storage_url,
@ -53,7 +52,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification
{
$message = new DiscordMessage(
title: ':warning: Database backup succeeded locally, S3 upload failed',
description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.",
description: "Database backup for {$this->name} was created successfully on local storage, but failed to upload to S3.",
color: DiscordMessage::warningColor(),
);
@ -69,7 +68,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification
public function toTelegram(): array
{
$message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}";
$message = "Coolify: Database backup for {$this->name} with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}";
if ($this->s3_storage_url) {
$message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}";
@ -82,7 +81,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification
public function toPushover(): PushoverMessage
{
$message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.<br/><br/><b>Frequency:</b> {$this->frequency}.<br/><b>S3 Error:</b> {$this->s3_error}";
$message = "Database backup for {$this->name} was created successfully on local storage, but failed to upload to S3.<br/><br/><b>Frequency:</b> {$this->frequency}.<br/><b>S3 Error:</b> {$this->s3_error}";
if ($this->s3_storage_url) {
$message .= "<br/><br/><a href=\"{$this->s3_storage_url}\">Check S3 Configuration</a>";
@ -98,7 +97,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification
public function toSlack(): SlackMessage
{
$title = 'Database backup succeeded locally, S3 upload failed';
$description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.";
$description = "Database backup for {$this->name} was created successfully on local storage, but failed to upload to S3.";
$description .= "\n\n*Frequency:* {$this->frequency}";
$description .= "\n\n*S3 Error:* {$this->s3_error}";
@ -124,7 +123,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification
'event' => 'backup_success_with_s3_warning',
'database_name' => $this->name,
'database_uuid' => $this->database->uuid,
'database_type' => $this->database_name,
'frequency' => $this->frequency,
's3_error' => $this->s3_error,
'url' => $url,

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('scheduled_database_backup_executions', function (Blueprint $table) {
$table->boolean('legacy')->default(false)->after('filename');
});
// Mark all existing backup executions as legacy
DB::table('scheduled_database_backup_executions')->update(['legacy' => true]);
// @todo: In a future update or v5 we no longer need "databases_to_backup", "dump_all" in "scheduled_database_backups" - keeping them for now if we need to rollback.
// @todo: In a future update or v5 we no longer need "database_name" from "scheduled_database_backup_executions" - keeping them for now if we need to rollback.
}
};

View file

@ -3940,15 +3940,6 @@
"type": "string",
"description": "S3 storage UUID (required if save_s3 is true)"
},
"databases_to_backup": {
"type": "string",
"description": "Comma separated list of databases to backup"
},
"dump_all": {
"type": "boolean",
"description": "Whether to dump all databases",
"default": false
},
"backup_now": {
"type": "boolean",
"description": "Whether to trigger backup immediately after creation"
@ -4502,14 +4493,6 @@
"type": "boolean",
"description": "Whether the backup is enabled or not"
},
"databases_to_backup": {
"type": "string",
"description": "Comma separated list of databases to backup"
},
"dump_all": {
"type": "boolean",
"description": "Whether all databases are dumped or not"
},
"frequency": {
"type": "string",
"description": "Frequency of the backup"

View file

@ -2471,13 +2471,6 @@ paths:
s3_storage_uuid:
type: string
description: 'S3 storage UUID (required if save_s3 is true)'
databases_to_backup:
type: string
description: 'Comma separated list of databases to backup'
dump_all:
type: boolean
description: 'Whether to dump all databases'
default: false
backup_now:
type: boolean
description: 'Whether to trigger backup immediately after creation'
@ -2860,12 +2853,6 @@ paths:
enabled:
type: boolean
description: 'Whether the backup is enabled or not'
databases_to_backup:
type: string
description: 'Comma separated list of databases to backup'
dump_all:
type: boolean
description: 'Whether all databases are dumped or not'
frequency:
type: string
description: 'Frequency of the backup'

View file

@ -1,5 +1,5 @@
<x-emails.layout>
Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} was FAILED.
Database backup for {{ $name }} with frequency of {{ $frequency }} was FAILED.
### Reason

View file

@ -1,5 +1,5 @@
<x-emails.layout>
Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} succeeded locally but failed to upload to S3.
Database backup for {{ $name }} with frequency of {{ $frequency }} succeeded locally but failed to upload to S3.
S3 Error: {{ $s3_error }}

View file

@ -1,3 +1,3 @@
<x-emails.layout>
Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} was successful.
Database backup for {{ $name }} with frequency of {{ $frequency }} was successful.
</x-emails.layout>

View file

@ -1,19 +0,0 @@
<x-emails.layout>
@foreach ($databases as $database_name => $databases)
@if(data_get($databases,'failed_count') > 0)
<div style="color:red">
"{{ $database_name }}" backups: There were some failed backups. Please login and check the logs for more details.
</div>
@else
"{{ $database_name }}" backups: All backups were successful.
@endif
@endforeach
</x-emails.layout>

View file

@ -46,40 +46,6 @@
@endif
<div class="flex flex-col gap-2">
<h3>Settings</h3>
<div class="flex gap-2 flex-col ">
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0)
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneMongodb')
<x-forms.input label="Databases To Include"
helper="A list of databases to backup. You can specify which collection(s) per database to exclude from the backup. Empty will include all databases and collections.<br><br>Example:<br><br>database1:collection1,collection2|database2:collection3,collection4<br><br> database1 will include all collections except collection1 and collection2. <br>database2 will include all collections except collection3 and collection4.<br><br>Another Example:<br><br>database1:collection1|database2<br><br> database1 will include all collections except collection1.<br>database2 will include ALL collections."
id="databasesToBackup" />
@elseif($backup->database_type === 'App\Models\StandaloneMysql')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneMariadb')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@endif
</div>
<div class="flex gap-2">
<x-forms.input label="Frequency" id="frequency" />
<x-forms.input label="Timezone" id="timezone" disabled

View file

@ -66,6 +66,11 @@
@endphp
{{ $statusText }}
</span>
@if (data_get($execution, 'legacy', false))
<span class="px-2 py-1 rounded-md text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-200">
Legacy
</span>
@endif
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
@if (data_get($execution, 'status') === 'running')
@ -79,7 +84,6 @@
{{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->format('M j, H:i') }}
</span>
@endif
Database: {{ data_get($execution, 'database_name', 'N/A') }}
@if(data_get($execution, 'size'))
Size: {{ formatBytes(data_get($execution, 'size')) }}
@endif

View file

@ -61,39 +61,49 @@
@if (str($resourceStatus)->startsWith('running'))
{{-- Restore Command Configuration --}}
@if ($resourceDbType === 'standalone-postgresql')
@if ($dumpAll)
<x-forms.textarea rows="6" readonly label="Custom Import Command"
wire:model='restoreCommandText'></x-forms.textarea>
@else
<x-forms.input label="Custom Import Command" wire:model='postgresqlRestoreCommand'></x-forms.input>
<div class="flex flex-col gap-1 pt-1">
<span class="text-xs">You can add "--clean" to drop objects before creating them, avoiding
conflicts.</span>
<span class="text-xs">You can add "--verbose" to log more things.</span>
<div class="flex flex-col gap-2 pt-4">
<h3>Import Options</h3>
<div class="flex flex-col gap-1 w-96">
<x-forms.checkbox label="Legacy Backup all databases Backups" wire:model.live='legacyDumpAll'
helper="For legacy backups created with the 'Backup all databases' option."></x-forms.checkbox>
<x-forms.checkbox label="Legacy Single DB Backups" wire:model.live='legacySingleDb'
helper="Uses pg_restore for single database restores for backups with the format .dmp."></x-forms.checkbox>
</div>
@endif
<div class="w-64 pt-2">
<x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox>
<x-forms.input label="Restore Command" wire:model='postgresqlRestoreCommand'
helper="Default uses psql to restore from pg_dumpall backups. Modify if needed."></x-forms.input>
@if ($restoreCommandText)
<div class="text-xs text-neutral-500">{{ $restoreCommandText }}</div>
@endif
</div>
@elseif ($resourceDbType === 'standalone-mysql')
@if ($dumpAll)
<x-forms.textarea rows="14" readonly label="Custom Import Command"
wire:model='restoreCommandText'></x-forms.textarea>
@else
<x-forms.input label="Custom Import Command" wire:model='mysqlRestoreCommand'></x-forms.input>
@endif
<div class="w-64 pt-2">
<x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox>
<div class="flex flex-col gap-2 pt-4">
<h3>Import Options</h3>
<div class="flex flex-col gap-1 w-96">
<x-forms.checkbox label="Legacy Backup all databases Backups" wire:model.live='legacyDumpAll'
helper="For legacy backups created with the 'Backup all databases' option."></x-forms.checkbox>
<x-forms.checkbox label="Legacy Single DB Backups" wire:model.live='legacySingleDb'
helper="Uses mysql for single database restores for backups with the format .sql."></x-forms.checkbox>
</div>
<x-forms.input label="Restore Command" wire:model='mysqlRestoreCommand'
helper="Default restores from mysqldump --all-databases backups. Modify if needed."></x-forms.input>
@if ($restoreCommandText)
<div class="text-xs text-neutral-500">{{ $restoreCommandText }}</div>
@endif
</div>
@elseif ($resourceDbType === 'standalone-mariadb')
@if ($dumpAll)
<x-forms.textarea rows="14" readonly label="Custom Import Command"
wire:model='restoreCommandText'></x-forms.textarea>
@else
<x-forms.input label="Custom Import Command" wire:model='mariadbRestoreCommand'></x-forms.input>
@endif
<div class="w-64 pt-2">
<x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox>
<div class="flex flex-col gap-2 pt-4">
<h3>Import Options</h3>
<div class="flex flex-col gap-1 w-96">
<x-forms.checkbox label="Legacy Backup all databases Backups" wire:model.live='legacyDumpAll'
helper="For legacy backups created with the 'Backup all databases' option."></x-forms.checkbox>
<x-forms.checkbox label="Legacy Single DB Backups" wire:model.live='legacySingleDb'
helper="Uses mariadb for single database restores for backups with the format .sql."></x-forms.checkbox>
</div>
<x-forms.input label="Restore Command" wire:model='mariadbRestoreCommand'
helper="Default restores from mariadb-dump --all-databases backups. Modify if needed."></x-forms.input>
@if ($restoreCommandText)
<div class="text-xs text-neutral-500">{{ $restoreCommandText }}</div>
@endif
</div>
@endif

View file

@ -101,20 +101,17 @@ test('app:init marks stuck database backup executions as failed', function () {
$runningBackup1 = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $scheduledBackup->id,
'status' => 'running',
'database_name' => 'test_db',
]);
$runningBackup2 = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $scheduledBackup->id,
'status' => 'running',
'database_name' => 'test_db_2',
]);
// Create a successful backup (should not be affected)
$successfulBackup = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $scheduledBackup->id,
'status' => 'success',
'database_name' => 'test_db_3',
'finished_at' => Carbon::now()->subMinutes(20),
]);

View file

@ -1,79 +0,0 @@
<?php
use App\Livewire\Project\Database\Import;
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
$component = new Import;
$component->dumpAll = false;
$component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('pg_restore');
expect($result)->toContain('/tmp/test.dump');
});
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
$component = new Import;
$component->dumpAll = true;
// This is the full dump-all command prefix that would be set in the updatedDumpAll method
$component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres';
$database = Mockery::mock('App\Models\StandalonePostgresql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain('psql -U $POSTGRES_USER postgres');
});
test('buildRestoreCommand handles MySQL without dumpAll', function () {
$component = new Import;
$component->dumpAll = false;
$component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMysql');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
expect($result)->toContain('< /tmp/test.dump');
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
$component = new Import;
$component->dumpAll = false;
$component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
$database = Mockery::mock('App\Models\StandaloneMariadb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
});
test('buildRestoreCommand handles MongoDB', function () {
$component = new Import;
$component->dumpAll = false;
$component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
$database = Mockery::mock('App\Models\StandaloneMongodb');
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
$component->resource = $database;
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('/tmp/test.dump');
});