Merge pull request #4818 from peaklabs-dev/feat-backup-retention
Feat: Improve backup retention (for database backups)
This commit is contained in:
@@ -299,7 +299,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
throw new \Exception('Unsupported database type');
|
||||
}
|
||||
$size = $this->calculate_size();
|
||||
$this->remove_old_backups();
|
||||
if ($this->backup->save_s3) {
|
||||
$this->upload_to_s3();
|
||||
}
|
||||
@@ -323,6 +322,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->backup_output, $database));
|
||||
}
|
||||
}
|
||||
if ($this->backup_log && $this->backup_log->status === 'success') {
|
||||
removeOldBackups($this->backup);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw $e;
|
||||
} finally {
|
||||
@@ -457,19 +459,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false);
|
||||
}
|
||||
|
||||
private function remove_old_backups(): void
|
||||
{
|
||||
if ($this->backup->number_of_backups_locally === 0) {
|
||||
$deletable = $this->backup->executions()->where('status', 'success');
|
||||
} else {
|
||||
$deletable = $this->backup->executions()->where('status', 'success')->skip($this->backup->number_of_backups_locally - 1);
|
||||
}
|
||||
foreach ($deletable->get() as $execution) {
|
||||
delete_backup_locally($execution->filename, $this->server);
|
||||
$execution->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function upload_to_s3(): void
|
||||
{
|
||||
try {
|
||||
|
@@ -43,8 +43,23 @@ class BackupEdit extends Component
|
||||
#[Validate(['string'])]
|
||||
public string $timezone = '';
|
||||
|
||||
#[Validate(['required', 'integer', 'min:1'])]
|
||||
public int $numberOfBackupsLocally = 1;
|
||||
#[Validate(['required', 'integer'])]
|
||||
public int $databaseBackupRetentionAmountLocally = 0;
|
||||
|
||||
#[Validate(['required', 'integer'])]
|
||||
public ?int $databaseBackupRetentionDaysLocally = 0;
|
||||
|
||||
#[Validate(['required', 'numeric', 'min:0'])]
|
||||
public ?float $databaseBackupRetentionMaxStorageLocally = 0;
|
||||
|
||||
#[Validate(['required', 'integer'])]
|
||||
public ?int $databaseBackupRetentionAmountS3 = 0;
|
||||
|
||||
#[Validate(['required', 'integer'])]
|
||||
public ?int $databaseBackupRetentionDaysS3 = 0;
|
||||
|
||||
#[Validate(['required', 'numeric', 'min:0'])]
|
||||
public ?float $databaseBackupRetentionMaxStorageS3 = 0;
|
||||
|
||||
#[Validate(['required', 'boolean'])]
|
||||
public bool $saveS3 = false;
|
||||
@@ -73,7 +88,12 @@ class BackupEdit extends Component
|
||||
if ($toModel) {
|
||||
$this->backup->enabled = $this->backupEnabled;
|
||||
$this->backup->frequency = $this->frequency;
|
||||
$this->backup->number_of_backups_locally = $this->numberOfBackupsLocally;
|
||||
$this->backup->database_backup_retention_amount_locally = $this->databaseBackupRetentionAmountLocally;
|
||||
$this->backup->database_backup_retention_days_locally = $this->databaseBackupRetentionDaysLocally;
|
||||
$this->backup->database_backup_retention_max_storage_locally = $this->databaseBackupRetentionMaxStorageLocally;
|
||||
$this->backup->database_backup_retention_amount_s3 = $this->databaseBackupRetentionAmountS3;
|
||||
$this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3;
|
||||
$this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3;
|
||||
$this->backup->save_s3 = $this->saveS3;
|
||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||
@@ -84,7 +104,12 @@ class BackupEdit extends Component
|
||||
$this->backupEnabled = $this->backup->enabled;
|
||||
$this->frequency = $this->backup->frequency;
|
||||
$this->timezone = data_get($this->backup->server(), 'settings.server_timezone', 'Instance timezone');
|
||||
$this->numberOfBackupsLocally = $this->backup->number_of_backups_locally;
|
||||
$this->databaseBackupRetentionAmountLocally = $this->backup->database_backup_retention_amount_locally;
|
||||
$this->databaseBackupRetentionDaysLocally = $this->backup->database_backup_retention_days_locally;
|
||||
$this->databaseBackupRetentionMaxStorageLocally = $this->backup->database_backup_retention_max_storage_locally;
|
||||
$this->databaseBackupRetentionAmountS3 = $this->backup->database_backup_retention_amount_s3;
|
||||
$this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3;
|
||||
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
|
||||
$this->saveS3 = $this->backup->save_s3;
|
||||
$this->s3StorageId = $this->backup->s3_storage_id;
|
||||
$this->databasesToBackup = $this->backup->databases_to_backup;
|
||||
@@ -103,11 +128,29 @@ class BackupEdit extends Component
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->delete_associated_backups_locally) {
|
||||
$this->deleteAssociatedBackupsLocally();
|
||||
$server = null;
|
||||
if ($this->backup->database instanceof \App\Models\ServiceDatabase) {
|
||||
$server = $this->backup->database->service->destination->server;
|
||||
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
|
||||
$server = $this->backup->database->destination->server;
|
||||
}
|
||||
if ($this->delete_associated_backups_s3) {
|
||||
$this->deleteAssociatedBackupsS3();
|
||||
|
||||
$filenames = $this->backup->executions()
|
||||
->whereNotNull('filename')
|
||||
->where('filename', '!=', '')
|
||||
->where('scheduled_database_backup_id', $this->backup->id)
|
||||
->pluck('filename')
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
if (! empty($filenames)) {
|
||||
if ($this->delete_associated_backups_locally && $server) {
|
||||
deleteBackupsLocally($filenames, $server);
|
||||
}
|
||||
|
||||
if ($this->delete_associated_backups_s3 && $this->backup->s3) {
|
||||
deleteBackupsS3($filenames, $this->backup->s3);
|
||||
}
|
||||
}
|
||||
|
||||
$this->backup->delete();
|
||||
@@ -123,7 +166,9 @@ class BackupEdit extends Component
|
||||
} else {
|
||||
return redirect()->route('project.database.backup.index', $this->parameters);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
} catch (\Exception $e) {
|
||||
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
@@ -160,63 +205,12 @@ class BackupEdit extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteAssociatedBackupsLocally()
|
||||
{
|
||||
$executions = $this->backup->executions;
|
||||
$backupFolder = null;
|
||||
|
||||
foreach ($executions as $execution) {
|
||||
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
$server = $this->backup->database->service->destination->server;
|
||||
} else {
|
||||
$server = $this->backup->database->destination->server;
|
||||
}
|
||||
|
||||
if (! $backupFolder) {
|
||||
$backupFolder = dirname($execution->filename);
|
||||
}
|
||||
|
||||
delete_backup_locally($execution->filename, $server);
|
||||
$execution->delete();
|
||||
}
|
||||
|
||||
if (str($backupFolder)->isNotEmpty()) {
|
||||
$this->deleteEmptyBackupFolder($backupFolder, $server);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteAssociatedBackupsS3()
|
||||
{
|
||||
// Add function to delete backups from S3
|
||||
}
|
||||
|
||||
private function deleteAssociatedBackupsSftp()
|
||||
{
|
||||
// Add function to delete backups from SFTP
|
||||
}
|
||||
|
||||
private function deleteEmptyBackupFolder($folderPath, $server)
|
||||
{
|
||||
$checkEmpty = instant_remote_process(["[ -z \"$(ls -A '$folderPath')\" ] && echo 'empty' || echo 'not empty'"], $server);
|
||||
|
||||
if (trim($checkEmpty) === 'empty') {
|
||||
instant_remote_process(["rmdir '$folderPath'"], $server);
|
||||
|
||||
$parentFolder = dirname($folderPath);
|
||||
$checkParentEmpty = instant_remote_process(["[ -z \"$(ls -A '$parentFolder')\" ] && echo 'empty' || echo 'not empty'"], $server);
|
||||
|
||||
if (trim($checkParentEmpty) === 'empty') {
|
||||
instant_remote_process(["rmdir '$parentFolder'"], $server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.database.backup-edit', [
|
||||
'checkboxes' => [
|
||||
['id' => 'delete_associated_backups_locally', 'label' => __('database.delete_backups_locally')],
|
||||
// ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected S3 Storage.']
|
||||
['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job for this database will be permanently deleted from the selected S3 Storage.'],
|
||||
// ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.']
|
||||
],
|
||||
]);
|
||||
|
@@ -18,9 +18,9 @@ class BackupExecutions extends Component
|
||||
|
||||
public $setDeletableBackup;
|
||||
|
||||
public $delete_backup_s3 = true;
|
||||
public $delete_backup_s3 = false;
|
||||
|
||||
public $delete_backup_sftp = true;
|
||||
public $delete_backup_sftp = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
@@ -57,23 +57,25 @@ class BackupExecutions extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
if ($execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server);
|
||||
} else {
|
||||
delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server);
|
||||
}
|
||||
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class
|
||||
? $execution->scheduledDatabaseBackup->database->service->destination->server
|
||||
: $execution->scheduledDatabaseBackup->database->destination->server;
|
||||
|
||||
if ($this->delete_backup_s3) {
|
||||
// Add logic to delete from S3
|
||||
}
|
||||
try {
|
||||
if ($execution->filename) {
|
||||
deleteBackupsLocally($execution->filename, $server);
|
||||
|
||||
if ($this->delete_backup_sftp) {
|
||||
// Add logic to delete from SFTP
|
||||
}
|
||||
if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) {
|
||||
deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3);
|
||||
}
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
$this->dispatch('success', 'Backup deleted.');
|
||||
$this->refreshBackupExecutions();
|
||||
$execution->delete();
|
||||
$this->dispatch('success', 'Backup deleted.');
|
||||
$this->refreshBackupExecutions();
|
||||
} catch (\Exception $e) {
|
||||
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function download_file($exeuctionId)
|
||||
@@ -143,7 +145,7 @@ class BackupExecutions extends Component
|
||||
return view('livewire.project.database.backup-executions', [
|
||||
'checkboxes' => [
|
||||
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'],
|
||||
['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'],
|
||||
// ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
@@ -11,21 +12,18 @@ use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
function generate_database_name(string $type): string
|
||||
{
|
||||
$cuid = new Cuid2;
|
||||
|
||||
return $type.'-database-'.$cuid;
|
||||
return $type.'-database-'.(new Cuid2);
|
||||
}
|
||||
|
||||
function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destinationUuid)->first();
|
||||
if (! $destination) {
|
||||
throw new Exception('Destination not found');
|
||||
}
|
||||
$destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail();
|
||||
$database = new StandalonePostgresql;
|
||||
$database->name = generate_database_name('postgresql');
|
||||
$database->image = $databaseImage;
|
||||
@@ -43,10 +41,7 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $
|
||||
|
||||
function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
throw new Exception('Destination not found');
|
||||
}
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneRedis;
|
||||
$database->name = generate_database_name('redis');
|
||||
$redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
@@ -77,10 +72,7 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
|
||||
|
||||
function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
throw new Exception('Destination not found');
|
||||
}
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMongodb;
|
||||
$database->name = generate_database_name('mongodb');
|
||||
$database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
@@ -94,12 +86,10 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o
|
||||
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
throw new Exception('Destination not found');
|
||||
}
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMysql;
|
||||
$database->name = generate_database_name('mysql');
|
||||
$database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
@@ -114,12 +104,10 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth
|
||||
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
throw new Exception('Destination not found');
|
||||
}
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneMariadb;
|
||||
$database->name = generate_database_name('mariadb');
|
||||
$database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
@@ -127,7 +115,6 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o
|
||||
$database->environment_id = $environment_id;
|
||||
$database->destination_id = $destination->id;
|
||||
$database->destination_type = $destination->getMorphClass();
|
||||
|
||||
if ($otherData) {
|
||||
$database->fill($otherData);
|
||||
}
|
||||
@@ -135,12 +122,10 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o
|
||||
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
throw new Exception('Destination not found');
|
||||
}
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneKeydb;
|
||||
$database->name = generate_database_name('keydb');
|
||||
$database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
@@ -157,10 +142,7 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth
|
||||
|
||||
function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
throw new Exception('Destination not found');
|
||||
}
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneDragonfly;
|
||||
$database->name = generate_database_name('dragonfly');
|
||||
$database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
@@ -174,12 +156,10 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array
|
||||
|
||||
return $database;
|
||||
}
|
||||
|
||||
function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse
|
||||
{
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
if (! $destination) {
|
||||
throw new Exception('Destination not found');
|
||||
}
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
|
||||
$database = new StandaloneClickhouse;
|
||||
$database->name = generate_database_name('clickhouse');
|
||||
$database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
|
||||
@@ -194,12 +174,231 @@ function create_standalone_clickhouse($environment_id, $destination_uuid, ?array
|
||||
return $database;
|
||||
}
|
||||
|
||||
function delete_backup_locally(?string $filename, Server $server): void
|
||||
function deleteBackupsLocally(string|array|null $filenames, Server $server): void
|
||||
{
|
||||
if (empty($filename)) {
|
||||
if (empty($filenames)) {
|
||||
return;
|
||||
}
|
||||
instant_remote_process(["rm -f \"{$filename}\""], $server, throwError: false);
|
||||
if (is_string($filenames)) {
|
||||
$filenames = [$filenames];
|
||||
}
|
||||
$quotedFiles = array_map(fn ($file) => "\"$file\"", $filenames);
|
||||
instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: false);
|
||||
|
||||
$foldersToCheck = collect($filenames)->map(fn ($file) => dirname($file))->unique();
|
||||
$foldersToCheck->each(fn ($folder) => deleteEmptyBackupFolder($folder, $server));
|
||||
}
|
||||
|
||||
function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void
|
||||
{
|
||||
if (empty($filenames) || ! $s3) {
|
||||
return;
|
||||
}
|
||||
if (is_string($filenames)) {
|
||||
$filenames = [$filenames];
|
||||
}
|
||||
|
||||
$disk = Storage::build([
|
||||
'driver' => 's3',
|
||||
'key' => $s3->key,
|
||||
'secret' => $s3->secret,
|
||||
'region' => $s3->region,
|
||||
'bucket' => $s3->bucket,
|
||||
'endpoint' => $s3->endpoint,
|
||||
'use_path_style_endpoint' => true,
|
||||
'bucket_endpoint' => $s3->isHetzner() || $s3->isDigitalOcean(),
|
||||
'aws_url' => $s3->awsUrl(),
|
||||
]);
|
||||
|
||||
$disk->delete($filenames);
|
||||
}
|
||||
|
||||
function deleteEmptyBackupFolder($folderPath, Server $server): void
|
||||
{
|
||||
$escapedPath = escapeshellarg($folderPath);
|
||||
$escapedParentPath = escapeshellarg(dirname($folderPath));
|
||||
|
||||
$checkEmpty = instant_remote_process(["[ -d $escapedPath ] && [ -z \"$(ls -A $escapedPath)\" ] && echo 'empty' || echo 'not empty'"], $server, throwError: false);
|
||||
|
||||
if (trim($checkEmpty) === 'empty') {
|
||||
instant_remote_process(["rmdir $escapedPath"], $server, throwError: false);
|
||||
$checkParentEmpty = instant_remote_process(["[ -d $escapedParentPath ] && [ -z \"$(ls -A $escapedParentPath)\" ] && echo 'empty' || echo 'not empty'"], $server, throwError: false);
|
||||
if (trim($checkParentEmpty) === 'empty') {
|
||||
instant_remote_process(["rmdir $escapedParentPath"], $server, throwError: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeOldBackups($backup): void
|
||||
{
|
||||
try {
|
||||
$processedBackups = deleteOldBackupsLocally($backup);
|
||||
|
||||
if ($backup->save_s3) {
|
||||
$processedBackups = $processedBackups->merge(deleteOldBackupsFromS3($backup));
|
||||
}
|
||||
|
||||
if ($processedBackups->isNotEmpty()) {
|
||||
$backup->executions()->whereIn('id', $processedBackups->pluck('id'))->delete();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteOldBackupsLocally($backup): Collection
|
||||
{
|
||||
if (! $backup || ! $backup->executions) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($successfulBackups->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_locally;
|
||||
$retentionDays = $backup->database_backup_retention_days_locally;
|
||||
$maxStorageGB = $backup->database_backup_retention_max_storage_locally;
|
||||
|
||||
if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$backupsToDelete = collect();
|
||||
|
||||
if ($retentionAmount > 0) {
|
||||
$byAmount = $successfulBackups->skip($retentionAmount);
|
||||
$backupsToDelete = $backupsToDelete->merge($byAmount);
|
||||
}
|
||||
|
||||
if ($retentionDays > 0) {
|
||||
$oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays);
|
||||
$oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate);
|
||||
$backupsToDelete = $backupsToDelete->merge($oldBackups);
|
||||
}
|
||||
|
||||
if ($maxStorageGB > 0) {
|
||||
$maxStorageBytes = $maxStorageGB * pow(1024, 3);
|
||||
$totalSize = 0;
|
||||
$backupsOverLimit = collect();
|
||||
|
||||
$backupsToCheck = $successfulBackups->skip(1);
|
||||
|
||||
foreach ($backupsToCheck as $backupExecution) {
|
||||
$totalSize += (int) $backupExecution->size;
|
||||
if ($totalSize > $maxStorageBytes) {
|
||||
$backupsOverLimit = $successfulBackups->filter(
|
||||
fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc()
|
||||
)->skip(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->merge($backupsOverLimit);
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->unique('id');
|
||||
$processedBackups = collect();
|
||||
|
||||
$server = null;
|
||||
if ($backup->database_type === \App\Models\ServiceDatabase::class) {
|
||||
$server = $backup->database->service->server;
|
||||
} else {
|
||||
$server = $backup->database->destination->server;
|
||||
}
|
||||
|
||||
if (! $server) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$filesToDelete = $backupsToDelete
|
||||
->filter(fn ($execution) => ! empty($execution->filename))
|
||||
->pluck('filename')
|
||||
->all();
|
||||
|
||||
if (! empty($filesToDelete)) {
|
||||
deleteBackupsLocally($filesToDelete, $server);
|
||||
$processedBackups = $backupsToDelete;
|
||||
}
|
||||
|
||||
return $processedBackups;
|
||||
}
|
||||
|
||||
function deleteOldBackupsFromS3($backup): Collection
|
||||
{
|
||||
if (! $backup || ! $backup->executions || ! $backup->s3) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
if ($successfulBackups->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_s3;
|
||||
$retentionDays = $backup->database_backup_retention_days_s3;
|
||||
$maxStorageGB = $backup->database_backup_retention_max_storage_s3;
|
||||
|
||||
if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$backupsToDelete = collect();
|
||||
|
||||
if ($retentionAmount > 0) {
|
||||
$byAmount = $successfulBackups->skip($retentionAmount);
|
||||
$backupsToDelete = $backupsToDelete->merge($byAmount);
|
||||
}
|
||||
|
||||
if ($retentionDays > 0) {
|
||||
$oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays);
|
||||
$oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate);
|
||||
$backupsToDelete = $backupsToDelete->merge($oldBackups);
|
||||
}
|
||||
|
||||
if ($maxStorageGB > 0) {
|
||||
$maxStorageBytes = $maxStorageGB * pow(1024, 3);
|
||||
$totalSize = 0;
|
||||
$backupsOverLimit = collect();
|
||||
|
||||
$backupsToCheck = $successfulBackups->skip(1);
|
||||
|
||||
foreach ($backupsToCheck as $backupExecution) {
|
||||
$totalSize += (int) $backupExecution->size;
|
||||
if ($totalSize > $maxStorageBytes) {
|
||||
$backupsOverLimit = $successfulBackups->filter(
|
||||
fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc()
|
||||
)->skip(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->merge($backupsOverLimit);
|
||||
}
|
||||
|
||||
$backupsToDelete = $backupsToDelete->unique('id');
|
||||
$processedBackups = collect();
|
||||
|
||||
$filesToDelete = $backupsToDelete
|
||||
->filter(fn ($execution) => ! empty($execution->filename))
|
||||
->pluck('filename')
|
||||
->all();
|
||||
|
||||
if (! empty($filesToDelete)) {
|
||||
deleteBackupsS3($filesToDelete, $backup->s3);
|
||||
$processedBackups = $backupsToDelete;
|
||||
}
|
||||
|
||||
return $processedBackups;
|
||||
}
|
||||
|
||||
function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool
|
||||
|
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('scheduled_database_backups', function (Blueprint $table) {
|
||||
$table->renameColumn('number_of_backups_locally', 'database_backup_retention_amount_locally');
|
||||
$table->integer('database_backup_retention_amount_locally')->default(0)->nullable(false)->change();
|
||||
$table->integer('database_backup_retention_days_locally')->default(0)->nullable(false);
|
||||
$table->decimal('database_backup_retention_max_storage_locally', 17, 7)->default(0)->nullable(false);
|
||||
|
||||
$table->integer('database_backup_retention_amount_s3')->default(0)->nullable(false);
|
||||
$table->integer('database_backup_retention_days_s3')->default(0)->nullable(false);
|
||||
$table->decimal('database_backup_retention_max_storage_s3', 17, 7)->default(0)->nullable(false);
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('scheduled_database_backups', function (Blueprint $table) {
|
||||
$table->renameColumn('database_backup_retention_amount_locally', 'number_of_backups_locally')->nullable(true)->change();
|
||||
$table->dropColumn([
|
||||
'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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
@@ -5,17 +5,13 @@
|
||||
Save
|
||||
</x-forms.button>
|
||||
@if (str($status)->startsWith('running'))
|
||||
<livewire:project.database.backup-now :backup="$backup" />
|
||||
<livewire:project.database.backup-now :backup="$backup" />
|
||||
@endif
|
||||
@if ($backup->database_id !== 0)
|
||||
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" buttonTitle="Delete Backups and Schedule"
|
||||
isErrorButton submitAction="delete" :checkboxes="$checkboxes" :actions="[
|
||||
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" buttonTitle="Delete Backups and Schedule" isErrorButton submitAction="delete" :checkboxes="$checkboxes" :actions="[
|
||||
'The selected backup schedule will be deleted.',
|
||||
'Scheduled backups for this database will be stopped (if this is the only backup schedule for this database).',
|
||||
]"
|
||||
confirmationText="{{ $backup->database->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Database Name of the scheduled backups below"
|
||||
shortConfirmationLabel="Database Name" />
|
||||
]" confirmationText="{{ $backup->database->name }}" confirmationLabel="Please confirm the execution of the actions by entering the Database Name of the scheduled backups below" shortConfirmationLabel="Database Name" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="w-48 pb-2">
|
||||
@@ -23,56 +19,88 @@
|
||||
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3" />
|
||||
</div>
|
||||
@if ($backup->save_s3)
|
||||
<div class="pb-6">
|
||||
<x-forms.select id="s3StorageId" label="S3 Storage" required>
|
||||
<option value="default">Select a S3 storage</option>
|
||||
@foreach ($s3s as $s3)
|
||||
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<div class="pb-6">
|
||||
<x-forms.select id="s3StorageId" label="S3 Storage" required>
|
||||
<option value="default">Select a S3 storage</option>
|
||||
@foreach ($s3s as $s3)
|
||||
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
@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
|
||||
<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" />
|
||||
<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
|
||||
<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
|
||||
<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
|
||||
helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" />
|
||||
<x-forms.input label="Number of backups to keep (locally)" id="numberOfBackupsLocally" />
|
||||
<x-forms.input label="Timezone" id="timezone" disabled helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" />
|
||||
</div>
|
||||
|
||||
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
|
||||
<div class="mb-4">
|
||||
<ul class="list-disc pl-6 space-y-2">
|
||||
<li>Setting a value to 0 means unlimited retention</li>
|
||||
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 flex-col">
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">Local Backup Retention</h4>
|
||||
<div class="flex gap-4">
|
||||
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountLocally" type="number" min="0" helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." />
|
||||
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysLocally" type="number" min="0" helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
|
||||
<x-forms.input
|
||||
label="Maximum storage (GB)"
|
||||
id="databaseBackupRetentionMaxStorageLocally"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0000001"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($backup->save_s3)
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">S3 Storage Retention</h4>
|
||||
<div class="flex gap-4">
|
||||
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountS3" type="number" min="0" helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." />
|
||||
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysS3" type="number" min="0" helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
|
||||
<x-forms.input
|
||||
label="Maximum storage (GB)"
|
||||
id="databaseBackupRetentionMaxStorageS3"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0000001"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@@ -47,6 +47,7 @@
|
||||
@endif
|
||||
<x-modal-confirmation title="Confirm Backup Deletion?" buttonTitle="Delete" isErrorButton
|
||||
submitAction="deleteBackup({{ data_get($execution, 'id') }})"
|
||||
:checkboxes="$checkboxes"
|
||||
:actions="['This backup will be permanently deleted from local storage.']" confirmationText="{{ data_get($execution, 'filename') }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
|
||||
shortConfirmationLabel="Backup Filename" step3ButtonText="Permanently Delete" />
|
||||
|
Reference in New Issue
Block a user