Files
coolify/bootstrap/helpers/databases.php
peaklabs-dev ee93ccd8e7 fix(backups): retention settings
- If you set a low local backup retention, for example 2 backups for local backup retention and 10 backups for S3, then the S3 backups were never deleted, not even after 10 days. This was because we check the file paths based on the backup executions table, and as soon as a backup was deleted locally, the execution was removed, which meant after 10 days for s3 there where no backups older then 10 days just the 2 local backups which is just wrong. Now we only delete a backup execution if it has been removed from both locations.
- Also added a nice little UI element to see where your backup is available.
2025-03-14 15:28:12 +01:00

434 lines
15 KiB
PHP

<?php
use App\Models\EnvironmentVariable;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
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
{
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)->firstOrFail();
$database = new StandalonePostgresql;
$database->name = generate_database_name('postgresql');
$database->image = $databaseImage;
$database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environmentId;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
if ($otherData) {
$database->fill($otherData);
}
$database->save();
return $database;
}
function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis
{
$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);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
if ($otherData) {
$database->fill($otherData);
}
$database->save();
EnvironmentVariable::create([
'key' => 'REDIS_PASSWORD',
'value' => $redis_password,
'resourceable_type' => StandaloneRedis::class,
'resourceable_id' => $database->id,
'is_shared' => false,
]);
EnvironmentVariable::create([
'key' => 'REDIS_USERNAME',
'value' => 'default',
'resourceable_type' => StandaloneRedis::class,
'resourceable_id' => $database->id,
'is_shared' => false,
]);
return $database;
}
function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb
{
$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);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
if ($otherData) {
$database->fill($otherData);
}
$database->save();
return $database;
}
function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql
{
$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);
$database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
if ($otherData) {
$database->fill($otherData);
}
$database->save();
return $database;
}
function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb
{
$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);
$database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
if ($otherData) {
$database->fill($otherData);
}
$database->save();
return $database;
}
function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb
{
$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);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
if ($otherData) {
$database->fill($otherData);
}
$database->save();
return $database;
}
function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly
{
$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);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
if ($otherData) {
$database->fill($otherData);
}
$database->save();
return $database;
}
function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse
{
$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);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
if ($otherData) {
$database->fill($otherData);
}
$database->save();
return $database;
}
function deleteBackupsLocally(string|array|null $filenames, Server $server): void
{
if (empty($filenames)) {
return;
}
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,
'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 {
if ($backup->executions) {
$localBackupsToDelete = deleteOldBackupsLocally($backup);
if ($localBackupsToDelete->isNotEmpty()) {
$backup->executions()
->whereIn('id', $localBackupsToDelete->pluck('id'))
->update(['local_storage_deleted' => true]);
}
}
if ($backup->save_s3 && $backup->executions) {
$s3BackupsToDelete = deleteOldBackupsFromS3($backup);
if ($s3BackupsToDelete->isNotEmpty()) {
$backup->executions()
->whereIn('id', $s3BackupsToDelete->pluck('id'))
->update(['s3_storage_deleted' => true]);
}
}
$backup->executions()
->where('local_storage_deleted', true)
->where('s3_storage_deleted', true)
->delete();
} catch (\Exception $e) {
throw $e;
}
}
function deleteOldBackupsLocally($backup): Collection
{
if (! $backup || ! $backup->executions) {
return collect();
}
$successfulBackups = $backup->executions()
->where('status', 'success')
->where('local_storage_deleted', false)
->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')
->where('s3_storage_deleted', false)
->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
{
if ($id) {
$foundDatabase = $server->databases()->where('public_port', $port)->where('is_public', true)->where('id', '!=', $id)->first();
} else {
$foundDatabase = $server->databases()->where('public_port', $port)->where('is_public', true)->first();
}
if ($foundDatabase) {
return true;
}
return false;
}