From b126eaf794ecd07e3e6174ce3f487c48fa9ce5f2 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 10 Dec 2025 00:55:34 +0000 Subject: [PATCH 1/7] feat: pgbackrest pgBackRest Further code quality changes Clean up code Small fix Hopefully the last bit of cleanup? Remove old comment! A couple more improvements Squash migrations --- app/Actions/Database/PgBackrestRestore.php | 53 +++ app/Actions/Database/StartPostgresql.php | 151 +++++- .../Controllers/Api/DatabasesController.php | 321 ++++++++++++- app/Jobs/DatabaseBackupJob.php | 201 ++++++++ app/Jobs/PgBackrestRestoreJob.php | 439 ++++++++++++++++++ app/Livewire/Project/Database/BackupEdit.php | 226 +++++++-- .../Project/Database/BackupExecutions.php | 100 +++- app/Models/DatabaseRestore.php | 77 +++ app/Models/PgbackrestRepo.php | 72 +++ app/Models/ScheduledDatabaseBackup.php | 61 +++ .../ScheduledDatabaseBackupExecution.php | 24 + app/Models/StandalonePostgresql.php | 40 ++ app/Notifications/Database/BackupFailed.php | 15 +- app/Notifications/Database/BackupSuccess.php | 15 +- .../Database/DatabaseRestoreFailed.php | 129 +++++ .../Database/DatabaseRestoreSuccess.php | 131 ++++++ app/Services/Backup/PgBackrestService.php | 353 ++++++++++++++ bootstrap/helpers/databases.php | 4 + bootstrap/helpers/shared.php | 16 + ...duled_database_backup_executions_table.php | 82 ++++ ..._231050_create_database_restores_table.php | 43 ++ openapi.json | 287 ++++++++++++ openapi.yaml | 164 +++++++ .../emails/database-restore-failed.blade.php | 8 + .../emails/database-restore-success.blade.php | 9 + .../project/database/backup-edit.blade.php | 296 +++++++++--- .../database/backup-executions.blade.php | 322 +++++++++++-- routes/api.php | 4 + templates/service-templates-latest.json | 16 + templates/service-templates.json | 16 + 30 files changed, 3509 insertions(+), 166 deletions(-) create mode 100644 app/Actions/Database/PgBackrestRestore.php create mode 100644 app/Jobs/PgBackrestRestoreJob.php create mode 100644 app/Models/DatabaseRestore.php create mode 100644 app/Models/PgbackrestRepo.php create mode 100644 app/Notifications/Database/DatabaseRestoreFailed.php create mode 100644 app/Notifications/Database/DatabaseRestoreSuccess.php create mode 100644 app/Services/Backup/PgBackrestService.php create mode 100644 database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php create mode 100644 database/migrations/2025_12_09_231050_create_database_restores_table.php create mode 100644 resources/views/emails/database-restore-failed.blade.php create mode 100644 resources/views/emails/database-restore-success.blade.php diff --git a/app/Actions/Database/PgBackrestRestore.php b/app/Actions/Database/PgBackrestRestore.php new file mode 100644 index 000000000..05a78340c --- /dev/null +++ b/app/Actions/Database/PgBackrestRestore.php @@ -0,0 +1,53 @@ +exists(); + $attempts++; + if ($attempts >= 3 && $exists) { + throw new \Exception('Unable to generate unique UUID for restore after 3 attempts'); + } + } while ($exists); + + $restore = DatabaseRestore::create([ + 'uuid' => $uuid, + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'engine' => 'pgbackrest', + 'scheduled_database_backup_execution_id' => $execution?->id, + 'target_label' => $execution?->pgbackrest_label, + 'target_time' => $targetTime, + 'status' => 'pending', + ]); + + PgBackrestRestoreJob::dispatch($database, $restore, $execution, $targetTime); + + return $restore; + } + + public function rules(): array + { + return [ + 'database' => ['required'], + ]; + } +} diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 41e39c811..e2a3f4df8 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -5,6 +5,7 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandalonePostgresql; +use App\Services\Backup\PgBackrestService; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -22,14 +23,13 @@ class StartPostgresql private ?SslCertificate $ssl_certificate = null; + private bool $hasPgBackrest = false; + public function handle(StandalonePostgresql $database) { $this->database = $database; $container_name = $this->database->uuid; $this->configuration_dir = database_configuration_dir().'/'.$container_name; - if (isDev()) { - $this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name; - } $this->commands = [ "echo 'Starting database.'", @@ -97,6 +97,7 @@ class StartPostgresql $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->generate_init_scripts(); + $this->setup_pgbackrest_config(); $this->add_custom_conf(); $docker_compose = [ @@ -173,11 +174,12 @@ class StartPostgresql if (count($this->init_scripts) > 0) { foreach ($this->init_scripts as $init_script) { + $hostInitScript = $this->getHostPath($init_script); $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'], [[ 'type' => 'bind', - 'source' => $init_script, + 'source' => $hostInitScript, 'target' => '/docker-entrypoint-initdb.d/'.basename($init_script), 'read_only' => true, ]] @@ -188,11 +190,12 @@ class StartPostgresql $command = ['postgres']; if (filled($this->database->postgres_conf)) { + $hostConfigPath = $this->getHostPath($this->configuration_dir.'/custom-postgres.conf'); $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'], [[ 'type' => 'bind', - 'source' => $this->configuration_dir.'/custom-postgres.conf', + 'source' => $hostConfigPath, 'target' => '/etc/postgresql/postgresql.conf', 'read_only' => true, ]] @@ -208,6 +211,22 @@ class StartPostgresql ]); } + if ($this->hasPgBackrest) { + $hostPgbackrestDir = $this->getHostPath($this->configuration_dir.'/pgbackrest'); + $docker_compose['services'][$container_name]['volumes'] = array_merge( + $docker_compose['services'][$container_name]['volumes'], + [ + $hostPgbackrestDir.':/etc/pgbackrest', + ] + ); + $docker_compose['services'][$container_name]['entrypoint'] = [ + '/bin/sh', + '-c', + '/etc/pgbackrest/install-pgbackrest.sh && exec docker-entrypoint.sh "$@"', + '--', + ]; + } + // Add custom docker run options $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); @@ -229,6 +248,7 @@ class StartPostgresql if ($this->database->enable_ssl) { $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt"); } + $this->commands[] = "echo 'Database started.'"; return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); @@ -315,19 +335,132 @@ class StartPostgresql $filename = 'custom-postgres.conf'; $config_file_path = "$this->configuration_dir/$filename"; - if (blank($this->database->postgres_conf)) { + $content = $this->database->postgres_conf ?? ''; + + if (! str($content)->contains('listen_addresses')) { + $content .= "\nlisten_addresses = '*'"; + } + + if ($this->hasPgBackrest) { + $stanza = PgBackrestService::getStanzaName($this->database); + $configPath = PgBackrestService::CONFIG_PATH; + $archiveCommand = "test ! -f {$configPath}/pgbackrest.conf || (command -v pgbackrest >/dev/null 2>&1 && pgbackrest --stanza={$stanza} archive-push %p)"; + + if (! str($content)->contains('archive_mode')) { + $content .= "\narchive_mode = on"; + } + + if (str($content)->contains('archive_command')) { + $content = preg_replace("/archive_command\s*=\s*'[^']*'/", "archive_command = '{$archiveCommand}'", $content); + } else { + $content .= "\narchive_command = '{$archiveCommand}'"; + } + + if (! str($content)->contains('wal_level')) { + $content .= "\nwal_level = replica"; + } + if (! str($content)->contains('max_wal_senders')) { + $content .= "\nmax_wal_senders = 3"; + } + } + + $content = trim($content); + + if (blank($content)) { $this->commands[] = "rm -f $config_file_path"; return; } - $content = $this->database->postgres_conf; - if (! str($content)->contains('listen_addresses')) { - $content .= "\nlisten_addresses = '*'"; + if ($content !== ($this->database->postgres_conf ?? '')) { $this->database->postgres_conf = $content; $this->database->save(); } + $content_base64 = base64_encode($content); $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $config_file_path > /dev/null"; } + + private function setup_pgbackrest_config(): void + { + $pgbackrestConfig = PgBackrestService::generateConfig($this->database); + + if ($pgbackrestConfig === null) { + $this->commands[] = "rm -rf $this->configuration_dir/pgbackrest"; + $this->hasPgBackrest = false; + + return; + } + + $this->hasPgBackrest = true; + $configBase64 = base64_encode($pgbackrestConfig); + + $this->commands[] = "echo 'Setting up pgBackRest configuration.'"; + $this->commands[] = "rm -rf $this->configuration_dir/pgbackrest"; + $this->commands[] = "mkdir -p $this->configuration_dir/pgbackrest"; + $this->commands[] = "echo '{$configBase64}' | base64 -d | tee $this->configuration_dir/pgbackrest/pgbackrest.conf > /dev/null"; + + $this->create_pgbackrest_entrypoint(); + } + + private function create_pgbackrest_entrypoint(): void + { + $stanza = PgBackrestService::getStanzaName($this->database); + $installCmd = PgBackrestService::getInstallCommand(); + + $backup = $this->database->pgbackrestBackups()->where('enabled', true)->first(); + $s3EnvSetup = ''; + if ($backup) { + $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); + foreach ($s3EnvVars as $key => $value) { + $escapedValue = addslashes($value); + $s3EnvSetup .= "export {$key}=\"{$escapedValue}\"\n"; + } + } + + $installScript = << /dev/null; then + NEED_INSTALL=1 +fi + +if [ "\$NEED_INSTALL" = "1" ]; then + {$installCmd} +fi + +# Fix permissions for postgres user +chown -R postgres:postgres /tmp/pgbackrest /var/lib/pgbackrest /etc/pgbackrest 2>/dev/null || true +chmod -R 770 /tmp/pgbackrest /var/lib/pgbackrest 2>/dev/null || true + +# Set up S3 environment variables if configured +{$s3EnvSetup} + +# Create stanza if it doesn't exist (before PostgreSQL starts archiving) +if [ -d /var/lib/postgresql/data ] && [ -f /var/lib/postgresql/data/PG_VERSION ]; then + STANZA_CHECK=\$(su postgres -c "pgbackrest --stanza={$stanza} info" 2>&1 || true) + if echo "\$STANZA_CHECK" | grep -q 'missing stanza'; then + echo "Creating pgbackrest stanza..." + su postgres -c "pgbackrest --stanza={$stanza} stanza-create" + fi +fi +BASH; + + $installScriptBase64 = base64_encode($installScript); + $this->commands[] = "echo '{$installScriptBase64}' | base64 -d | tee $this->configuration_dir/pgbackrest/install-pgbackrest.sh > /dev/null"; + $this->commands[] = "chmod +x $this->configuration_dir/pgbackrest/install-pgbackrest.sh"; + } + + /** + * Convert a path from the SSH target perspective to the Docker host perspective. + */ + private function getHostPath(string $path): string + { + return convertPathToDockerHost($path); + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 0d38b7363..64be5ea47 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Database\PgBackrestRestore; use App\Actions\Database\RestartDatabase; use App\Actions\Database\StartDatabase; use App\Actions\Database\StartDatabaseProxy; @@ -11,9 +12,11 @@ use App\Enums\NewDatabaseTypes; use App\Http\Controllers\Controller; use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; +use App\Models\DatabaseRestore; use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use App\Models\ScheduledDatabaseBackupExecution; use App\Models\Server; use App\Models\StandalonePostgresql; use Illuminate\Http\Request; @@ -640,6 +643,12 @@ class DatabasesController extends Controller 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + 'engine' => ['type' => 'string', 'description' => 'Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)', 'enum' => ['native', 'pgbackrest'], 'default' => 'native'], + 'pgbackrest_backup_type' => ['type' => 'string', 'description' => 'pgBackRest backup type', 'enum' => ['full', 'diff', 'incr']], + 'pgbackrest_compress_type' => ['type' => 'string', 'description' => 'pgBackRest compression type', 'enum' => ['lz4', 'gzip', 'zstd', 'none']], + 'pgbackrest_compress_level' => ['type' => 'integer', 'description' => 'pgBackRest compression level (0-9)'], + 'pgbackrest_log_level' => ['type' => 'string', 'description' => 'pgBackRest log level', 'enum' => ['off', 'error', 'warn', 'info', 'detail', 'debug', 'trace']], + 'pgbackrest_archive_mode' => ['type' => 'string', 'description' => 'pgBackRest archive mode for WAL archiving', 'enum' => ['standard', 'reduced', 'minimal']], ], ), ) @@ -676,7 +685,15 @@ 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', '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', + 'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level', + 'pgbackrest_log_level', 'pgbackrest_archive_mode', + ]; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -703,6 +720,12 @@ class DatabasesController extends Controller 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'engine' => 'string|in:native,pgbackrest|nullable', + 'pgbackrest_backup_type' => 'string|in:full,diff,incr|nullable', + 'pgbackrest_compress_type' => 'string|in:lz4,gzip,zstd,none|nullable', + 'pgbackrest_compress_level' => 'integer|min:0|max:9|nullable', + 'pgbackrest_log_level' => 'string|in:off,error,warn,info,detail,debug,trace|nullable', + 'pgbackrest_archive_mode' => 'string|in:standard,reduced,minimal|nullable', ]); if ($validator->fails()) { @@ -724,6 +747,14 @@ class DatabasesController extends Controller $this->authorize('manageBackups', $database); + // Validate pgBackRest can only be used with PostgreSQL + if ($request->input('engine') === 'pgbackrest' && $database->type() !== 'standalone-postgresql') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['engine' => ['pgBackRest engine is only supported for PostgreSQL databases.']], + ], 422); + } + // Validate frequency is a valid cron expression $isValid = validate_cron_expression($request->frequency); if (! $isValid) { @@ -867,6 +898,12 @@ class DatabasesController extends Controller 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + 'engine' => ['type' => 'string', 'description' => 'Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)', 'enum' => ['native', 'pgbackrest']], + 'pgbackrest_backup_type' => ['type' => 'string', 'description' => 'pgBackRest backup type', 'enum' => ['full', 'diff', 'incr']], + 'pgbackrest_compress_type' => ['type' => 'string', 'description' => 'pgBackRest compression type', 'enum' => ['lz4', 'gzip', 'zstd', 'none']], + 'pgbackrest_compress_level' => ['type' => 'integer', 'description' => 'pgBackRest compression level (0-9)'], + 'pgbackrest_log_level' => ['type' => 'string', 'description' => 'pgBackRest log level', 'enum' => ['off', 'error', 'warn', 'info', 'detail', 'debug', 'trace']], + 'pgbackrest_archive_mode' => ['type' => 'string', 'description' => 'pgBackRest archive mode for WAL archiving', 'enum' => ['standard', 'reduced', 'minimal']], ], ), ) @@ -896,7 +933,15 @@ 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', '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', + 'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level', + 'pgbackrest_log_level', 'pgbackrest_archive_mode', + ]; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -921,6 +966,12 @@ class DatabasesController extends Controller 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'engine' => 'string|in:native,pgbackrest|nullable', + 'pgbackrest_backup_type' => 'string|in:full,diff,incr|nullable', + 'pgbackrest_compress_type' => 'string|in:lz4,gzip,zstd,none|nullable', + 'pgbackrest_compress_level' => 'integer|min:0|max:9|nullable', + 'pgbackrest_log_level' => 'string|in:off,error,warn,info,detail,debug,trace|nullable', + 'pgbackrest_archive_mode' => 'string|in:standard,reduced,minimal|nullable', ]); if ($validator->fails()) { return response()->json([ @@ -947,6 +998,14 @@ class DatabasesController extends Controller $this->authorize('update', $database); + // Validate pgBackRest can only be used with PostgreSQL + if ($request->input('engine') === 'pgbackrest' && $database->type() !== 'standalone-postgresql') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['engine' => ['pgBackRest engine is only supported for PostgreSQL databases.']], + ], 422); + } + if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { return response()->json([ 'message' => 'Validation failed.', @@ -2749,4 +2808,262 @@ class DatabasesController extends Controller 200 ); } + + #[OA\Post( + summary: 'Restore Database', + description: 'Restore a PostgreSQL database from a PgBackRest backup.', + path: '/databases/{uuid}/restore', + operationId: 'restore-database', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'execution_uuid', type: 'string', description: 'UUID of backup execution to restore from (optional, uses latest if not specified)'), + new OA\Property(property: 'target_time', type: 'string', description: 'ISO 8601 timestamp for point-in-time recovery (optional)'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Restore initiated', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Database restore initiated.'], + 'restore_uuid' => ['type' => 'string', 'example' => 'abc123'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function restore_database(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + if (! $database instanceof StandalonePostgresql) { + return response()->json(['message' => 'PgBackRest restore is only available for PostgreSQL databases.'], 400); + } + + $this->authorize('manage', $database); + + if (! $database->hasPgBackrestBackups()) { + return response()->json(['message' => 'No PgBackRest backup configuration found for this database.'], 400); + } + + $execution = null; + if ($request->has('execution_uuid')) { + $execution = ScheduledDatabaseBackupExecution::where('uuid', $request->execution_uuid) + ->whereHas('scheduledDatabaseBackup', function ($query) use ($database) { + $query->where('database_id', $database->id) + ->where('database_type', $database->getMorphClass()) + ->where('engine', 'pgbackrest'); + }) + ->where('status', 'success') + ->first(); + + if (! $execution) { + return response()->json(['message' => 'Backup execution not found or not a successful PgBackRest backup.'], 404); + } + } + + $targetTime = $request->input('target_time'); + + $restore = PgBackrestRestore::run($database, $execution, $targetTime); + + return response()->json([ + 'message' => 'Database restore initiated.', + 'restore_uuid' => $restore->uuid, + ], 200); + } + + #[OA\Get( + summary: 'List Restores', + description: 'List all restore operations for a database.', + path: '/databases/{uuid}/restores', + operationId: 'list-database-restores', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of restores', + content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object')) + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function list_restores(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $restores = DatabaseRestore::where('database_id', $database->id) + ->where('database_type', $database->getMorphClass()) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json($restores); + } + + #[OA\Get( + summary: 'Restore Status', + description: 'Get the status of a restore operation.', + path: '/databases/{uuid}/restores/{restore_uuid}', + operationId: 'get-restore-status', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'restore_uuid', + in: 'path', + description: 'UUID of the restore operation.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Restore status', + content: new OA\JsonContent(type: 'object') + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function restore_status(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $uuid = $request->route('uuid'); + $restoreUuid = $request->route('restore_uuid'); + + if (! $uuid || ! $restoreUuid) { + return response()->json(['message' => 'UUID and restore_uuid are required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $restore = DatabaseRestore::where('uuid', $restoreUuid) + ->where('database_id', $database->id) + ->where('database_type', $database->getMorphClass()) + ->first(); + + if (! $restore) { + return response()->json(['message' => 'Restore not found.'], 404); + } + + return response()->json($restore); + } } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a585baa69..533090770 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -16,6 +16,7 @@ use App\Models\Team; use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccessWithS3Warning; +use App\Services\Backup\PgBackrestService; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -113,6 +114,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue if (! $status->startsWith('running') && $this->database->id !== 0) { return; } + + if ($this->backup->isPgBackrest() && $this->database instanceof StandalonePostgresql) { + $this->run_pgbackrest_backup(); + + return; + } + if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) { $databaseType = $this->database->databaseType(); $serviceUuid = $this->database->service->uuid; @@ -682,6 +690,199 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return "{$helperImage}:{$latestVersion}"; } + private function run_pgbackrest_backup(): void + { + $stanza = PgBackrestService::getStanzaName($this->database); + $backupType = $this->backup->pgbackrest_backup_type ?: 'full'; + $this->container_name = $this->database->uuid; + + $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); + + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, + 'database_name' => $this->database->postgres_db, + 'scheduled_database_backup_id' => $this->backup->id, + 'status' => 'running', + 'engine' => 'pgbackrest', + 'pgbackrest_backup_type' => $backupType, + 'pgbackrest_stanza' => $stanza, + ]); + + try { + $this->update_pgbackrest_config(); + + $backupCmd = PgBackrestService::buildBackupCommand($stanza, $backupType, 'info'); + + $s3EnvVars = PgBackrestService::buildS3EnvVars($this->backup); + if (! empty($s3EnvVars)) { + $envExport = ''; + foreach ($s3EnvVars as $key => $value) { + $escapedValue = addslashes($value); + $envExport .= "export {$key}=\"{$escapedValue}\"; "; + } + $backupFullCmd = "docker exec {$this->container_name} sh -c '{$envExport} {$backupCmd} 2>&1; echo \"EXIT_CODE:\$?\"'"; + } else { + $backupFullCmd = "docker exec {$this->container_name} sh -c '{$backupCmd} 2>&1; echo \"EXIT_CODE:\$?\"'"; + } + + $rawOutput = instant_remote_process([$backupFullCmd], $this->server, false, false, $this->timeout, disableMultiplexing: true); + $rawOutput = trim($rawOutput) ?: ''; + + $exitCode = 0; + if (preg_match('/EXIT_CODE:(\d+)$/', $rawOutput, $matches)) { + $exitCode = (int) $matches[1]; + $this->backup_output = trim(preg_replace('/EXIT_CODE:\d+$/', '', $rawOutput)) ?: null; + } else { + $this->backup_output = $rawOutput ?: null; + } + + if ($exitCode !== 0) { + $errorMessage = $this->backup_output ?: "pgBackRest backup failed with exit code {$exitCode}"; + throw new \RuntimeException($errorMessage, $exitCode); + } + + $infoCmd = PgBackrestService::buildInfoCommand($stanza, true); + + if (! empty($s3EnvVars)) { + $envExport = ''; + foreach ($s3EnvVars as $key => $value) { + $escapedValue = addslashes($value); + $envExport .= "export {$key}=\"{$escapedValue}\"; "; + } + $infoJson = instant_remote_process( + ["docker exec {$this->container_name} sh -c '{$envExport} {$infoCmd}'"], + $this->server, + false, + false, + 120, + disableMultiplexing: true + ); + } else { + $infoJson = instant_remote_process( + ["docker exec {$this->container_name} {$infoCmd}"], + $this->server, + false, + false, + 120, + disableMultiplexing: true + ); + } + + $info = PgBackrestService::parseInfoJson($infoJson); + $latestBackup = $info ? PgBackrestService::getLatestBackup($info) : null; + + $label = $latestBackup['label'] ?? null; + $type = $latestBackup ? PgBackrestService::getBackupType($latestBackup) : $backupType; + $size = $latestBackup ? PgBackrestService::getBackupSize($latestBackup) : 0; + + $this->run_pgbackrest_expire($stanza); + + $this->backup_log->update([ + 'status' => 'success', + 'message' => $this->backup_output, + 'size' => $size, + 'filename' => "pgbackrest:{$label}", + 'engine' => 'pgbackrest', + 'pgbackrest_backup_type' => $type, + 'pgbackrest_label' => $label, + 'pgbackrest_repo_size' => $size, + 's3_uploaded' => $this->backup->hasS3Repo() ? true : null, + 'finished_at' => Carbon::now(), + ]); + + $this->team->notify(new BackupSuccess($this->backup, $this->database, $this->database->postgres_db)); + + } catch (Throwable $e) { + $errorMsg = $this->backup_output ?? $e->getMessage(); + if ($this->backup_output && $e->getMessage() !== $this->backup_output) { + $errorMsg = $this->backup_output; + } + + $this->backup_log->update([ + 'status' => 'failed', + 'message' => $errorMsg, + 'finished_at' => Carbon::now(), + ]); + + $this->team->notify(new BackupFailed( + $this->backup, + $this->database, + $errorMsg, + $this->database->postgres_db + )); + + throw $e; + } finally { + BackupCreated::dispatch($this->team->id); + } + } + + private function run_pgbackrest_expire(string $stanza): void + { + try { + $repos = $this->backup->enabledPgbackrestRepos()->get(); + + foreach ($repos as $repo) { + $expireCmd = PgBackrestService::buildExpireCommand($stanza, $repo->repo_number); + + $s3EnvVars = PgBackrestService::buildS3EnvVars($this->backup); + if (! empty($s3EnvVars)) { + $envExport = ''; + foreach ($s3EnvVars as $key => $value) { + $escapedValue = addslashes($value); + $envExport .= "export {$key}=\"{$escapedValue}\"; "; + } + instant_remote_process( + ["docker exec {$this->container_name} sh -c '{$envExport} {$expireCmd}'"], + $this->server, + false, + false, + $this->timeout, + disableMultiplexing: true + ); + } else { + instant_remote_process( + ["docker exec {$this->container_name} {$expireCmd}"], + $this->server, + false, + false, + $this->timeout, + disableMultiplexing: true + ); + } + } + } catch (Throwable $e) { + Log::warning('PgBackRest expire failed', [ + 'stanza' => $stanza, + 'error' => $e->getMessage(), + ]); + } + } + + private function update_pgbackrest_config(): void + { + $config = PgBackrestService::generateConfig($this->database); + + if ($config === null) { + throw new \Exception('No valid pgBackRest repository configured. Please configure at least one repository (local or S3).'); + } + + $configBase64 = base64_encode($config); + $configPath = PgBackrestService::CONFIG_PATH; + + instant_remote_process([ + "docker exec {$this->container_name} sh -c 'echo {$configBase64} | base64 -d > {$configPath}/pgbackrest.conf'", + ], $this->server, true, false, 60, disableMultiplexing: true); + } + public function failed(?Throwable $exception): void { Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [ diff --git a/app/Jobs/PgBackrestRestoreJob.php b/app/Jobs/PgBackrestRestoreJob.php new file mode 100644 index 000000000..e2906a510 --- /dev/null +++ b/app/Jobs/PgBackrestRestoreJob.php @@ -0,0 +1,439 @@ +onQueue('high'); + } + + public function handle(): void + { + $server = $this->database->destination->server; + $containerName = $this->database->uuid; + $stanza = PgBackrestService::getStanzaName($this->database); + $backupTimestamp = time(); + + try { + $this->restore->updateStatus('running', 'Starting PgBackRest restore.'); + + $this->preflight($stanza); + + $this->restore->appendLog('Stopping PostgreSQL container.'); + StopDatabase::run($this->database, dockerCleanup: false); + + $this->restore->appendLog('Backing up current PGDATA before restore.'); + $backupPath = $this->backupCurrentPgData($backupTimestamp); + + try { + $this->restore->appendLog('Clearing PGDATA directory.'); + $this->clearPgData(); + + $this->restore->appendLog('Restoring via PgBackRest sidecar container.'); + $this->runRestoreSidecar($stanza); + + $this->restore->appendLog('Verifying restored database.'); + $this->verifyRestore(); + + $this->restore->appendLog('Starting PostgreSQL after restore.'); + StartPostgresql::run($this->database); + + // Restore succeeded, safely remove the backup + $this->restore->appendLog('Removing backup of previous database.'); + $this->removePgDataBackup($backupPath); + + $this->restore->updateStatus('success', 'PgBackRest restore completed successfully.'); + + $team = $this->database->team(); + if ($team) { + $team->notify(new DatabaseRestoreSuccess( + $this->database, + $this->restore->target_label, + $this->targetTime + )); + } + } catch (Throwable $restoreError) { + // Restore failed, attempt to recover + $this->restore->appendLog('Restore failed, attempting to recover from backup: '.$restoreError->getMessage()); + $this->recoverFromBackup($backupPath); + throw $restoreError; + } + } catch (Throwable $e) { + Log::error('PgBackRest restore failed', [ + 'database' => $this->database->uuid, + 'restore_id' => $this->restore->uuid, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->restore->updateStatus('failed', 'Restore failed: '.$e->getMessage()); + + $team = $this->database->team(); + if ($team) { + $team->notify(new DatabaseRestoreFailed( + $this->database, + $e->getMessage(), + $this->restore->target_label + )); + } + + try { + $this->restore->appendLog('Attempting to restart PostgreSQL after failed restore.'); + StartPostgresql::run($this->database); + } catch (Throwable $restartError) { + $this->restore->appendLog('Failed to restart PostgreSQL: '.$restartError->getMessage()); + } + + throw $e; + } + } + + private function preflight(string $stanza): void + { + $this->restore->appendLog('Running pre-flight validation.'); + + if (! $this->database->hasPgBackrestBackups()) { + throw new \RuntimeException('No PgBackRest backup configuration found for this database.'); + } + + $backup = $this->database->pgbackrestBackups()->where('enabled', true)->first(); + if (! $backup) { + throw new \RuntimeException('No enabled PgBackRest backup configuration found.'); + } + + $s3Repos = $backup->pgbackrestRepos()->where('type', 's3')->where('enabled', true)->get(); + foreach ($s3Repos as $s3Repo) { + $s3 = $s3Repo->s3Storage; + if (! $s3) { + throw new \RuntimeException("S3 storage configuration not found for repo {$s3Repo->repo_number}."); + } + + try { + $s3->testConnection(shouldSave: true); + } catch (Throwable $e) { + throw new \RuntimeException("S3 connection test failed for repo {$s3Repo->repo_number}: ".$e->getMessage()); + } + } + + $pgdataVolume = $this->database->pgdataVolume(); + if (! $pgdataVolume) { + throw new \RuntimeException('PGDATA volume not found.'); + } + + $repoVolume = $this->database->pgbackrestRepoVolume(); + $hasLocalRepo = $backup->hasLocalRepo(); + if (! $repoVolume && $hasLocalRepo) { + throw new \RuntimeException('PgBackRest repository volume not found.'); + } + + $this->restore->appendLog('Verifying backup exists in repository via sidecar container.'); + $info = $this->runInfoSidecar($stanza, $backup); + + if (! PgBackrestService::stanzaExists($info)) { + throw new \RuntimeException('PgBackRest stanza does not exist or is not healthy.'); + } + + if (! PgBackrestService::hasBackups($info)) { + throw new \RuntimeException('No backups found in PgBackRest repository.'); + } + + if ($this->execution && $this->execution->pgbackrest_label) { + $targetBackup = PgBackrestService::findBackupByLabel($info, $this->execution->pgbackrest_label); + if (! $targetBackup) { + throw new \RuntimeException("Backup with label '{$this->execution->pgbackrest_label}' not found in repository."); + } + $this->restore->appendLog("Target backup verified: {$this->execution->pgbackrest_label}"); + } else { + $latestBackup = PgBackrestService::getLatestBackup($info); + if ($latestBackup) { + $this->restore->appendLog("Will restore from latest backup: {$latestBackup['label']}"); + } + } + + $this->restore->appendLog('Pre-flight validation passed.'); + } + + private function runInfoSidecar(string $stanza, $backup): array + { + $server = $this->database->destination->server; + $configDir = $this->getHostPath($this->database->workdir().'/pgbackrest'); + $network = $this->database->destination->network; + + $mounts = []; + $envPieces = []; + + $repoVolume = $this->database->pgbackrestRepoVolume(); + if ($repoVolume) { + $repoMount = $repoVolume->host_path ?: $repoVolume->name; + $mounts[] = "-v {$repoMount}:/var/lib/pgbackrest"; + } + + $mounts[] = "-v {$configDir}:/etc/pgbackrest:ro"; + + $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); + foreach ($s3EnvVars as $key => $value) { + $escapedValue = addslashes($value); + $envPieces[] = "-e {$key}=\"{$escapedValue}\""; + } + + $infoCmd = PgBackrestService::buildInfoCommand($stanza, true); + $sidecarName = 'pgbackrest-info-'.$this->database->uuid.'-'.time(); + + $cmd = sprintf( + 'docker run --rm --name %s --network %s %s %s %s sh -c \'%s\' 2>&1', + $sidecarName, + $network, + implode(' ', $envPieces), + implode(' ', $mounts), + $this->getSidecarImage(), + $this->getInstallAndRunCommand($infoCmd) + ); + + $output = instant_remote_process([$cmd], $server, false, false, 120, disableMultiplexing: true); + + if ($output === null || $output === '') { + throw new \RuntimeException('Failed to get PgBackRest info - command returned no output. Command: '.$cmd); + } + + $jsonStart = strpos($output, '['); + if ($jsonStart !== false) { + $output = substr($output, $jsonStart); + } + + $info = PgBackrestService::parseInfoJson($output); + if ($info === null) { + throw new \RuntimeException('Failed to parse PgBackRest info output: '.$output); + } + + return $info; + } + + private function backupCurrentPgData(int $timestamp): string + { + $server = $this->database->destination->server; + $pgdataVolume = $this->database->pgdataVolume(); + + if (! $pgdataVolume) { + throw new \RuntimeException('PGDATA volume not found.'); + } + + $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; + $backupPath = "{$mount}_backup_{$timestamp}"; + + $backupCmd = "docker run --rm -v {$mount}:/data -v {$mount}_backup_{$timestamp}:/backup alpine sh -c 'cp -a /data/* /backup/ 2>/dev/null || true'"; + instant_remote_process([$backupCmd], $server, false, false, 600, disableMultiplexing: true); + + $this->restore->appendLog("PGDATA backed up to temporary location: {$backupPath}"); + + return $backupPath; + } + + private function recoverFromBackup(string $backupPath): void + { + try { + $server = $this->database->destination->server; + $pgdataVolume = $this->database->pgdataVolume(); + + if (! $pgdataVolume) { + $this->restore->appendLog('Warning: PGDATA volume not found, cannot recover from backup.'); + + return; + } + + $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; + + $this->restore->appendLog('Recovering PGDATA from backup...'); + $recoverCmd = "docker run --rm -v {$mount}:/data -v {$backupPath}:/backup alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true && cp -a /backup/* /data/ 2>/dev/null || true'"; + instant_remote_process([$recoverCmd], $server, false, false, 600, disableMultiplexing: true); + + $this->restore->appendLog('PGDATA recovered from backup.'); + } catch (Throwable $e) { + $this->restore->appendLog('Failed to recover from backup: '.$e->getMessage()); + } + } + + private function removePgDataBackup(string $backupPath): void + { + try { + $server = $this->database->destination->server; + + $rmCmd = "docker run --rm -v {$backupPath}:/backup alpine sh -c 'rm -rf /backup/* /backup/.[!.]* /backup/..?* 2>/dev/null || true'"; + instant_remote_process([$rmCmd], $server, false, false, 300, disableMultiplexing: true); + + $this->restore->appendLog('Temporary backup removed.'); + } catch (Throwable $e) { + $this->restore->appendLog('Warning: Failed to remove temporary backup: '.$e->getMessage()); + } + } + + private function clearPgData(): void + { + $server = $this->database->destination->server; + $pgdataVolume = $this->database->pgdataVolume(); + + if (! $pgdataVolume) { + throw new \RuntimeException('PGDATA volume not found.'); + } + + $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; + + $rmCmd = "docker run --rm -v {$mount}:/data alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true'"; + instant_remote_process([$rmCmd], $server, false, false, 300, disableMultiplexing: true); + + $this->restore->appendLog('PGDATA directory cleared.'); + } + + private function verifyRestore(): void + { + try { + $pgdataVolume = $this->database->pgdataVolume(); + if (! $pgdataVolume) { + throw new \RuntimeException('PGDATA volume not found.'); + } + + $server = $this->database->destination->server; + $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; + + // Verify PG_VERSION exists in restored data + $checkCmd = "docker run --rm -v {$mount}:/data alpine test -f /data/PG_VERSION && echo 'OK' || echo 'FAIL'"; + $result = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true); + + if (trim($result) !== 'OK') { + throw new \RuntimeException('Restored PGDATA does not contain valid PostgreSQL data (PG_VERSION not found).'); + } + + $this->restore->appendLog('Restored database verified successfully.'); + } catch (Throwable $e) { + throw new \RuntimeException('Database verification failed: '.$e->getMessage()); + } + } + + private function runRestoreSidecar(string $stanza): void + { + $server = $this->database->destination->server; + $configDir = $this->getHostPath($this->database->workdir().'/pgbackrest'); + $network = $this->database->destination->network; + + $backup = $this->database->pgbackrestBackups()->where('enabled', true)->first(); + if (! $backup) { + throw new \RuntimeException('No enabled PgBackRest backup configuration found.'); + } + + $pgdataVolume = $this->database->pgdataVolume(); + $repoVolume = $this->database->pgbackrestRepoVolume(); + + $pgdataMount = $pgdataVolume->host_path ?: $pgdataVolume->name; + + $mounts = []; + $mounts[] = "-v {$pgdataMount}:".PgBackrestService::PGDATA_PATH; + + if ($repoVolume) { + $repoMount = $repoVolume->host_path ?: $repoVolume->name; + $mounts[] = "-v {$repoMount}:/var/lib/pgbackrest"; + } + + $mounts[] = "-v {$configDir}:/etc/pgbackrest:ro"; + + $envPieces = ['-e PGBACKREST_PG1_PATH='.PgBackrestService::PGDATA_PATH]; + + $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); + foreach ($s3EnvVars as $key => $value) { + $escapedValue = addslashes($value); + $envPieces[] = "-e {$key}=\"{$escapedValue}\""; + } + + $restoreCmd = PgBackrestService::buildRestoreCommand( + $stanza, + $this->execution?->pgbackrest_label, + $this->targetTime, + 'info' + ); + + $sidecarName = 'pgbackrest-restore-'.$this->database->uuid.'-'.time(); + + $fullRestoreScript = $this->getInstallAndRunCommand($restoreCmd).' && chown -R 999:999 '.PgBackrestService::PGDATA_PATH; + + $cmd = sprintf( + 'docker run --rm --name %s --network %s %s %s %s sh -c \'%s\' 2>&1', + $sidecarName, + $network, + implode(' ', $envPieces), + implode(' ', $mounts), + $this->getSidecarImage(), + $fullRestoreScript + ); + + $output = instant_remote_process([$cmd], $server, true, false, $this->timeout, disableMultiplexing: true); + + $this->restore->appendLog('Restore output: '.substr($output, 0, 2000)); + } + + private function getSidecarImage(): string + { + return $this->database->image; + } + + private function getInstallAndRunCommand(string $command): string + { + return PgBackrestService::buildInstallAndSetupCommand($command); + } + + /** + * Convert a path from the SSH target perspective to the Docker host perspective. + */ + private function getHostPath(string $path): string + { + return convertPathToDockerHost($path); + } + + public function failed(?Throwable $exception): void + { + Log::channel('scheduled-errors')->error('PgBackRest restore permanently failed', [ + 'job' => 'PgBackrestRestoreJob', + 'database' => $this->database->uuid, + 'restore_id' => $this->restore->uuid, + 'error' => $exception?->getMessage(), + 'trace' => $exception?->getTraceAsString(), + ]); + + $this->restore->updateStatus('failed', 'Restore job permanently failed: '.($exception?->getMessage() ?? 'Unknown error')); + + $team = $this->database->team(); + if ($team) { + $team->notify(new DatabaseRestoreFailed( + $this->database, + $exception?->getMessage() ?? 'Unknown error', + $this->restore->target_label + )); + } + } +} diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index d70c52411..7b1b35417 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -2,9 +2,13 @@ namespace App\Livewire\Project\Database; +use App\Models\InstanceSettings; +use App\Models\PgbackrestRepo; use App\Models\ScheduledDatabaseBackup; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -68,7 +72,7 @@ class BackupEdit extends Component public bool $disableLocalBackup = false; #[Validate(['nullable', 'integer'])] - public ?int $s3StorageId = 1; + public ?int $s3StorageId = null; #[Validate(['nullable', 'string'])] public ?string $databasesToBackup = null; @@ -79,6 +83,45 @@ class BackupEdit extends Component #[Validate(['required', 'int', 'min:60', 'max:36000'])] public int $timeout = 3600; + #[Validate(['required', 'string'])] + public string $engine = 'native'; + + #[Validate(['nullable', 'string', 'in:full,diff,incr'])] + public ?string $pgbackrestBackupType = 'full'; + + #[Validate(['nullable', 'string', 'in:none,bz2,gz,lz4,zst'])] + public ?string $pgbackrestCompressType = 'lz4'; + + #[Validate(['nullable', 'integer', 'min:0', 'max:9'])] + public ?int $pgbackrestCompressLevel = 6; + + #[Validate(['nullable', 'string', 'in:off,error,warn,info,detail,debug,trace'])] + public ?string $pgbackrestLogLevel = 'info'; + + #[Validate(['nullable', 'string', 'in:standard,reduced,minimal'])] + public ?string $pgbackrestArchiveMode = 'standard'; + + #[Validate(['nullable', 'string', 'in:count,time'])] + public ?string $localRepoRetentionFullType = 'count'; + + #[Validate(['nullable', 'integer', 'min:1'])] + public ?int $localRepoRetentionFull = 2; + + #[Validate(['nullable', 'integer', 'min:1'])] + public ?int $localRepoRetentionDiff = 7; + + #[Validate(['nullable', 'integer'])] + public ?int $s3RepoStorageId = null; + + #[Validate(['nullable', 'string', 'in:count,time'])] + public ?string $s3RepoRetentionFullType = 'count'; + + #[Validate(['nullable', 'integer', 'min:1'])] + public ?int $s3RepoRetentionFull = 2; + + #[Validate(['nullable', 'integer', 'min:1'])] + public ?int $s3RepoRetentionDiff = 7; + public function mount() { try { @@ -95,36 +138,52 @@ class BackupEdit extends Component if ($toModel) { $this->backup->enabled = $this->backupEnabled; $this->backup->frequency = $this->frequency; - $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->disable_local_backup = $this->disableLocalBackup; - $this->backup->s3_storage_id = $this->s3StorageId; + $this->backup->engine = $this->engine; - // 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() - ); + if ($this->engine === 'pgbackrest') { + $this->backup->save_s3 = $this->saveS3; + $this->backup->disable_local_backup = $this->saveS3 && $this->disableLocalBackup; + + $this->backup->pgbackrest_backup_type = $this->pgbackrestBackupType; + $this->backup->pgbackrest_compress_type = $this->pgbackrestCompressType; + $this->backup->pgbackrest_compress_level = $this->pgbackrestCompressLevel; + $this->backup->pgbackrest_log_level = $this->pgbackrestLogLevel; + $this->backup->pgbackrest_archive_mode = $this->pgbackrestArchiveMode; + + $this->backup->save(); + $this->syncPgbackrestRepos(); + } else { + $this->backup->save_s3 = $this->saveS3; + $this->backup->disable_local_backup = $this->disableLocalBackup; + $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->s3_storage_id = $this->s3StorageId; + + 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) { + $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->save(); } - $this->backup->databases_to_backup = $this->databasesToBackup; - $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; $this->customValidate(); $this->backup->save(); @@ -132,14 +191,28 @@ 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->engine = $this->backup->engine ?? 'native'; + + $this->pgbackrestBackupType = $this->backup->pgbackrest_backup_type ?? 'full'; + $this->pgbackrestCompressType = $this->backup->pgbackrest_compress_type ?? 'lz4'; + $this->pgbackrestCompressLevel = $this->backup->pgbackrest_compress_level ?? 6; + $this->pgbackrestLogLevel = $this->backup->pgbackrest_log_level ?? 'info'; + $this->pgbackrestArchiveMode = $this->backup->pgbackrest_archive_mode ?? 'standard'; + $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->disableLocalBackup = $this->backup->disable_local_backup ?? false; + + if ($this->engine === 'pgbackrest') { + $this->loadPgbackrestRepos(); + } else { + $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; @@ -147,12 +220,97 @@ class BackupEdit extends Component } } + private function loadPgbackrestRepos(): void + { + $localRepo = $this->backup->localRepo(); + $s3Repo = $this->backup->s3Repo(); + + $this->disableLocalBackup = $localRepo === null || ! $localRepo->enabled; + $this->saveS3 = $s3Repo !== null && $s3Repo->enabled; + + if ($localRepo) { + $this->localRepoRetentionFullType = $localRepo->retention_full_type ?? 'count'; + $this->localRepoRetentionFull = $localRepo->retention_full ?? 2; + $this->localRepoRetentionDiff = $localRepo->retention_diff ?? 7; + } + + if ($s3Repo) { + $this->s3RepoStorageId = $s3Repo->s3_storage_id; + $this->s3RepoRetentionFullType = $s3Repo->retention_full_type ?? 'count'; + $this->s3RepoRetentionFull = $s3Repo->retention_full ?? 2; + $this->s3RepoRetentionDiff = $s3Repo->retention_diff ?? 7; + } + } + + private function syncPgbackrestRepos(): void + { + $hasLocal = ! $this->disableLocalBackup; + $hasS3 = $this->saveS3 && ! empty($this->s3RepoStorageId); + + if (! $hasLocal && ! $hasS3) { + throw new \Exception( + 'At least one backup repository (local or S3) must be enabled for pgBackRest. '. + 'Either enable local backups or configure S3 storage and enable it.' + ); + } + + if ($hasS3 && empty($this->s3RepoStorageId)) { + throw new \Exception('S3 storage must be selected when S3 backups are enabled.'); + } + + $repoNumber = 1; + + if ($hasLocal) { + $localRepo = $this->backup->localRepo(); + if (! $localRepo) { + $localRepo = new PgbackrestRepo; + $localRepo->scheduled_database_backup_id = $this->backup->id; + $localRepo->type = 'posix'; + } + $localRepo->repo_number = $repoNumber; + $localRepo->enabled = true; + $localRepo->retention_full_type = $this->localRepoRetentionFullType ?? 'count'; + $localRepo->retention_full = $this->localRepoRetentionFull ?? 2; + $localRepo->retention_diff = $this->localRepoRetentionDiff ?? 7; + $localRepo->save(); + $repoNumber++; + } else { + $this->backup->pgbackrestRepos()->where('type', 'posix')->delete(); + } + + if ($hasS3) { + if (empty($this->s3RepoStorageId)) { + throw new \Exception('S3 Storage is required when enabling S3 backups for pgBackRest.'); + } + + $s3Repo = $this->backup->s3Repo(); + if (! $s3Repo) { + $s3Repo = new PgbackrestRepo; + $s3Repo->scheduled_database_backup_id = $this->backup->id; + $s3Repo->type = 's3'; + } + $s3Repo->repo_number = $repoNumber; + $s3Repo->enabled = true; + $s3Repo->s3_storage_id = $this->s3RepoStorageId; + $s3Repo->retention_full_type = $this->s3RepoRetentionFullType ?? 'count'; + $s3Repo->retention_full = $this->s3RepoRetentionFull ?? 2; + $s3Repo->retention_diff = $this->s3RepoRetentionDiff ?? 7; + $s3Repo->save(); + } else { + $this->backup->pgbackrestRepos()->where('type', 's3')->delete(); + } + } + public function delete($password) { $this->authorize('manageBackups', $this->backup->database); - if (! verifyPasswordConfirmation($password, $this)) { - return; + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } } try { @@ -219,7 +377,6 @@ class BackupEdit extends Component $this->backup->s3_storage_id = null; } - // Validate that disable_local_backup can only be true when S3 backup is enabled if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { $this->backup->disable_local_backup = $this->disableLocalBackup = false; } @@ -243,14 +400,19 @@ class BackupEdit extends Component } } + public function isPostgresql(): bool + { + return $this->backup->database_type === 'App\Models\StandalonePostgresql'; + } + 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 will be permanently deleted (associated with this backup job) 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.'] ], + 'isPostgresql' => $this->isPostgresql(), ]); } } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 44f903fcc..ad3f8c3fe 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -2,13 +2,22 @@ namespace App\Livewire\Project\Database; +use App\Actions\Database\PgBackrestRestore; +use App\Models\DatabaseRestore; +use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; +use App\Models\ScheduledDatabaseBackupExecution; +use App\Models\StandalonePostgresql; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class BackupExecutions extends Component { + use AuthorizesRequests; + public ?ScheduledDatabaseBackup $backup = null; public $database; @@ -33,6 +42,14 @@ class BackupExecutions extends Component public $delete_backup_sftp = false; + public ?int $restoreExecutionId = null; + + public ?DatabaseRestore $currentRestore = null; + + public bool $showRestoreModal = false; + + public bool $showRestoreProgressModal = false; + public function getListeners() { $userId = Auth::id(); @@ -67,8 +84,12 @@ class BackupExecutions extends Component public function deleteBackup($executionId, $password) { - if (! verifyPasswordConfirmation($password, $this)) { - return; + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } } $execution = $this->backup->executions()->where('id', $executionId)->first(); @@ -104,6 +125,81 @@ class BackupExecutions extends Component return redirect()->route('download.backup', $exeuctionId); } + public function confirmRestore(int $executionId) + { + $execution = ScheduledDatabaseBackupExecution::find($executionId); + if (! $execution || ! $execution->canRestore()) { + $this->dispatch('error', 'This backup cannot be restored.'); + + return; + } + + $this->restoreExecutionId = $executionId; + $this->showRestoreModal = true; + } + + public function cancelRestore() + { + $this->restoreExecutionId = null; + $this->showRestoreModal = false; + } + + public function startRestore(int $executionId, $password = null, $selectedActions = []) + { + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! $password || ! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + + $execution = ScheduledDatabaseBackupExecution::find($executionId); + if (! $execution || ! $execution->canRestore()) { + $this->dispatch('error', 'This backup cannot be restored.'); + + return; + } + + $database = $this->backup->database; + if (! $database instanceof StandalonePostgresql) { + $this->dispatch('error', 'PgBackRest restore is only available for PostgreSQL databases.'); + + return; + } + + $this->authorize('manage', $database); + + try { + $this->currentRestore = PgBackrestRestore::run($database, $execution); + $this->showRestoreModal = false; + $this->showRestoreProgressModal = true; + $this->dispatch('success', 'Restore initiated. The database will be temporarily unavailable.'); + } catch (\Exception $e) { + $this->dispatch('error', 'Failed to start restore: '.$e->getMessage()); + } + } + + public function pollRestoreStatus() + { + if (! $this->currentRestore) { + return; + } + + $this->currentRestore->refresh(); + + if ($this->currentRestore->isFinished()) { + $this->refreshBackupExecutions(); + } + } + + public function closeRestoreProgress() + { + $this->currentRestore = null; + $this->showRestoreProgressModal = false; + $this->refreshBackupExecutions(); + } + public function refreshBackupExecutions(): void { $this->loadExecutions(); diff --git a/app/Models/DatabaseRestore.php b/app/Models/DatabaseRestore.php new file mode 100644 index 000000000..f5945ead7 --- /dev/null +++ b/app/Models/DatabaseRestore.php @@ -0,0 +1,77 @@ + 'datetime', + 'finished_at' => 'datetime', + ]; + } + + public function database(): MorphTo + { + return $this->morphTo(); + } + + public function execution(): BelongsTo + { + return $this->belongsTo(ScheduledDatabaseBackupExecution::class, 'scheduled_database_backup_execution_id'); + } + + public function isRunning(): bool + { + return $this->status === 'running'; + } + + public function isPending(): bool + { + return $this->status === 'pending'; + } + + public function isSuccess(): bool + { + return $this->status === 'success'; + } + + public function isFailed(): bool + { + return $this->status === 'failed'; + } + + public function isFinished(): bool + { + return in_array($this->status, ['success', 'failed']); + } + + public function appendLog(string $message): void + { + $timestamp = now()->format('Y-m-d H:i:s'); + $this->log = ($this->log ?? '')."[{$timestamp}] {$message}\n"; + $this->save(); + } + + public function updateStatus(string $status, ?string $message = null): void + { + $this->status = $status; + + if ($message !== null) { + $this->message = $message; + $this->appendLog($message); + } + + if ($this->isFinished()) { + $this->finished_at = now(); + } + + $this->save(); + } +} diff --git a/app/Models/PgbackrestRepo.php b/app/Models/PgbackrestRepo.php new file mode 100644 index 000000000..0980175e5 --- /dev/null +++ b/app/Models/PgbackrestRepo.php @@ -0,0 +1,72 @@ + 'boolean', + 'repo_number' => 'integer', + 'retention_full' => 'integer', + 'retention_diff' => 'integer', + ]; + } + + protected static function booted(): void + { + static::creating(function ($repo) { + if (empty($repo->uuid)) { + $repo->uuid = (string) new Cuid2; + } + }); + } + + public function scheduledDatabaseBackup(): BelongsTo + { + return $this->belongsTo(ScheduledDatabaseBackup::class); + } + + public function s3Storage(): BelongsTo + { + return $this->belongsTo(S3Storage::class, 's3_storage_id'); + } + + public function isLocal(): bool + { + return $this->type === 'posix'; + } + + public function isS3(): bool + { + return $this->type === 's3'; + } + + public function getRepoKey(): string + { + return "repo{$this->repo_number}"; + } + + public function getDefaultPath(): string + { + if ($this->isS3()) { + $backup = $this->scheduledDatabaseBackup; + $database = $backup?->database; + + return '/'.($database?->uuid ?? 'backup'); + } + + return '/var/lib/pgbackrest'; + } + + public function getEffectivePath(): string + { + return $this->path ?: $this->getDefaultPath(); + } +} diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 3ade21df8..28315f34f 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -10,6 +10,67 @@ class ScheduledDatabaseBackup extends BaseModel { protected $guarded = []; + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + 'save_s3' => 'boolean', + 'dump_all' => 'boolean', + 'disable_local_backup' => 'boolean', + 'pgbackrest_compress_level' => 'integer', + ]; + } + + public function isPgBackrest(): bool + { + return $this->engine === 'pgbackrest'; + } + + public function isNative(): bool + { + return $this->engine === 'native' || $this->engine === null; + } + + public function pgbackrestRepos(): HasMany + { + return $this->hasMany(PgbackrestRepo::class)->orderBy('repo_number'); + } + + public function enabledPgbackrestRepos(): HasMany + { + return $this->hasMany(PgbackrestRepo::class)->where('enabled', true)->orderBy('repo_number'); + } + + public function localRepo(): ?PgbackrestRepo + { + return $this->pgbackrestRepos()->where('type', 'posix')->first(); + } + + public function s3Repo(): ?PgbackrestRepo + { + return $this->pgbackrestRepos()->where('type', 's3')->first(); + } + + public function hasLocalRepo(): bool + { + return $this->pgbackrestRepos()->where('type', 'posix')->where('enabled', true)->exists(); + } + + public function hasS3Repo(): bool + { + return $this->pgbackrestRepos()->where('type', 's3')->where('enabled', true)->exists(); + } + + public function restores(): HasMany + { + return $this->hasManyThrough( + DatabaseRestore::class, + ScheduledDatabaseBackupExecution::class, + 'scheduled_database_backup_id', + 'scheduled_database_backup_execution_id' + ); + } + public static function ownedByCurrentTeam() { return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('created_at', 'desc'); diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index c0298ecc8..cabac0749 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ScheduledDatabaseBackupExecution extends BaseModel { @@ -14,6 +15,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel 's3_uploaded' => 'boolean', 'local_storage_deleted' => 'boolean', 's3_storage_deleted' => 'boolean', + 'pgbackrest_repo_size' => 'integer', ]; } @@ -21,4 +23,26 @@ class ScheduledDatabaseBackupExecution extends BaseModel { return $this->belongsTo(ScheduledDatabaseBackup::class); } + + public function restores(): HasMany + { + return $this->hasMany(DatabaseRestore::class, 'scheduled_database_backup_execution_id'); + } + + public function isPgBackrest(): bool + { + return $this->engine === 'pgbackrest'; + } + + public function isNative(): bool + { + return $this->engine === 'native' || $this->engine === null; + } + + public function canRestore(): bool + { + return $this->isPgBackrest() + && $this->status === 'success' + && ! empty($this->pgbackrest_label); + } } diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 03080fd3d..3176b5c08 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -31,6 +31,14 @@ class StandalonePostgresql extends BaseModel 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), ]); + + LocalPersistentVolume::create([ + 'name' => 'postgres-pgbackrest-repo-'.$database->uuid, + 'mount_path' => '/var/lib/pgbackrest', + 'host_path' => null, + 'resource_id' => $database->id, + 'resource_type' => $database->getMorphClass(), + ]); }); static::forceDeleting(function ($database) { $database->persistentStorages()->delete(); @@ -335,6 +343,38 @@ class StandalonePostgresql extends BaseModel return true; } + public function hasPgBackrestBackups(): bool + { + return $this->scheduledBackups() + ->where('engine', 'pgbackrest') + ->where('enabled', true) + ->exists(); + } + + public function pgbackrestBackups() + { + return $this->scheduledBackups()->where('engine', 'pgbackrest'); + } + + public function restores() + { + return $this->morphMany(DatabaseRestore::class, 'database'); + } + + public function pgdataVolume(): ?LocalPersistentVolume + { + return $this->persistentStorages() + ->where('mount_path', '/var/lib/postgresql/data') + ->first(); + } + + public function pgbackrestRepoVolume(): ?LocalPersistentVolume + { + return $this->persistentStorages() + ->where('mount_path', '/var/lib/pgbackrest') + ->first(); + } + public function getCpuMetrics(int $mins = 5) { $server = $this->destination->server; diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index c2b21b1d5..c57241838 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -15,11 +15,17 @@ class BackupFailed extends CustomEmailNotification public string $frequency; + public string $engine; + + public ?string $backupType; + public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name) { $this->onQueue('high'); $this->name = $database->name; $this->frequency = $backup->frequency; + $this->engine = $backup->engine ?? 'native'; + $this->backupType = $backup->isPgBackrest() ? ($backup->pgbackrest_backup_type ?? 'full') : null; } public function via(object $notifiable): array @@ -93,7 +99,7 @@ class BackupFailed extends CustomEmailNotification { $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; - return [ + $payload = [ 'success' => false, 'message' => 'Database backup failed', 'event' => 'backup_failed', @@ -101,8 +107,15 @@ class BackupFailed extends CustomEmailNotification 'database_uuid' => $this->database->uuid, 'database_type' => $this->database_name, 'frequency' => $this->frequency, + 'engine' => $this->engine, 'error_output' => $this->output, 'url' => $url, ]; + + if ($this->backupType) { + $payload['backup_type'] = $this->backupType; + } + + return $payload; } } diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 3d2d8ece3..1b1fa92b6 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -15,12 +15,18 @@ class BackupSuccess extends CustomEmailNotification public string $frequency; + public string $engine; + + public ?string $backupType; + public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name) { $this->onQueue('high'); $this->name = $database->name; $this->frequency = $backup->frequency; + $this->engine = $backup->engine ?? 'native'; + $this->backupType = $backup->isPgBackrest() ? ($backup->pgbackrest_backup_type ?? 'full') : null; } public function via(object $notifiable): array @@ -90,7 +96,7 @@ class BackupSuccess extends CustomEmailNotification { $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; - return [ + $payload = [ 'success' => true, 'message' => 'Database backup successful', 'event' => 'backup_success', @@ -98,7 +104,14 @@ class BackupSuccess extends CustomEmailNotification 'database_uuid' => $this->database->uuid, 'database_type' => $this->database_name, 'frequency' => $this->frequency, + 'engine' => $this->engine, 'url' => $url, ]; + + if ($this->backupType) { + $payload['backup_type'] = $this->backupType; + } + + return $payload; } } diff --git a/app/Notifications/Database/DatabaseRestoreFailed.php b/app/Notifications/Database/DatabaseRestoreFailed.php new file mode 100644 index 000000000..e6fdca41d --- /dev/null +++ b/app/Notifications/Database/DatabaseRestoreFailed.php @@ -0,0 +1,129 @@ +onQueue('high'); + + $this->name = $database->name; + $this->error = $error; + $this->label = $label; + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failed'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: Database restore failed for {$this->name}"); + $mail->view('emails.database-restore-failed', [ + 'name' => $this->name, + 'error' => $this->error, + 'label' => $this->label, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $description = "Database restore for {$this->name} has failed."; + if ($this->label) { + $description .= " Attempted to restore from backup: {$this->label}"; + } + + $message = new DiscordMessage( + title: ':x: Database restore failed', + description: $description, + color: DiscordMessage::errorColor(), + ); + + $message->addField('Error', $this->error, false); + + return $message; + } + + public function toTelegram(): array + { + $message = "Coolify: Database restore for {$this->name} has failed."; + if ($this->label) { + $message .= " Backup: {$this->label}"; + } + $message .= "\n\nError: {$this->error}"; + + return [ + 'message' => $message, + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "Database restore for {$this->name} has failed."; + if ($this->label) { + $message .= "
Backup: {$this->label}"; + } + $message .= "

Error: {$this->error}"; + + return new PushoverMessage( + title: 'Database restore failed', + level: 'error', + message: $message, + ); + } + + public function toSlack(): SlackMessage + { + $title = 'Database restore failed'; + $description = "Database restore for {$this->name} has failed."; + + if ($this->label) { + $description .= "\n\n*Backup:* {$this->label}"; + } + $description .= "\n\n*Error:* {$this->error}"; + + return new SlackMessage( + title: $title, + description: $description, + color: SlackMessage::errorColor() + ); + } + + public function toWebhook(): array + { + $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + + return [ + 'success' => false, + 'message' => 'Database restore failed', + 'event' => 'restore_failed', + 'database_name' => $this->name, + 'database_uuid' => $this->database->uuid, + 'engine' => 'pgbackrest', + 'backup_label' => $this->label, + 'error' => $this->error, + 'url' => $url, + ]; + } +} diff --git a/app/Notifications/Database/DatabaseRestoreSuccess.php b/app/Notifications/Database/DatabaseRestoreSuccess.php new file mode 100644 index 000000000..6a001ce91 --- /dev/null +++ b/app/Notifications/Database/DatabaseRestoreSuccess.php @@ -0,0 +1,131 @@ +onQueue('high'); + + $this->name = $database->name; + $this->label = $label; + $this->targetTime = $targetTime; + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_success'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: Database restore successful for {$this->name}"); + $mail->view('emails.database-restore-success', [ + 'name' => $this->name, + 'label' => $this->label, + 'target_time' => $this->targetTime, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $description = "Database restore for {$this->name} was successful."; + if ($this->label) { + $description .= " Restored from backup: {$this->label}"; + } + + $message = new DiscordMessage( + title: ':white_check_mark: Database restore successful', + description: $description, + color: DiscordMessage::successColor(), + ); + + if ($this->targetTime) { + $message->addField('Target Time', $this->targetTime, true); + } + + return $message; + } + + public function toTelegram(): array + { + $message = "Coolify: Database restore for {$this->name} was successful."; + if ($this->label) { + $message .= " Restored from backup: {$this->label}"; + } + + return [ + 'message' => $message, + ]; + } + + public function toPushover(): PushoverMessage + { + $message = "Database restore for {$this->name} was successful."; + if ($this->label) { + $message .= "
Backup: {$this->label}"; + } + + return new PushoverMessage( + title: 'Database restore successful', + level: 'success', + message: $message, + ); + } + + public function toSlack(): SlackMessage + { + $title = 'Database restore successful'; + $description = "Database restore for {$this->name} was successful."; + + if ($this->label) { + $description .= "\n\n*Backup:* {$this->label}"; + } + if ($this->targetTime) { + $description .= "\n*Target Time:* {$this->targetTime}"; + } + + return new SlackMessage( + title: $title, + description: $description, + color: SlackMessage::successColor() + ); + } + + public function toWebhook(): array + { + $url = base_url().'/project/'.data_get($this->database, 'environment.project.uuid').'/environment/'.data_get($this->database, 'environment.uuid').'/database/'.$this->database->uuid; + + return [ + 'success' => true, + 'message' => 'Database restore successful', + 'event' => 'restore_success', + 'database_name' => $this->name, + 'database_uuid' => $this->database->uuid, + 'engine' => 'pgbackrest', + 'backup_label' => $this->label, + 'target_time' => $this->targetTime, + 'url' => $url, + ]; + } +} diff --git a/app/Services/Backup/PgBackrestService.php b/app/Services/Backup/PgBackrestService.php new file mode 100644 index 000000000..09708da4d --- /dev/null +++ b/app/Services/Backup/PgBackrestService.php @@ -0,0 +1,353 @@ +uuid; + } + + public static function generateConfig(StandalonePostgresql $database): ?string + { + $pgbackrestBackups = $database->pgbackrestBackups()->where('enabled', true)->get(); + + if ($pgbackrestBackups->isEmpty()) { + return null; + } + + $backup = $pgbackrestBackups->first(); + $stanza = self::getStanzaName($database); + + $repos = $backup->enabledPgbackrestRepos()->get(); + if ($repos->isEmpty()) { + return null; + } + + $config = "[global]\n"; + $config .= 'log-level-console='.($backup->pgbackrest_log_level ?? self::DEFAULT_LOG_LEVEL)."\n"; + $config .= 'log-level-file='.($backup->pgbackrest_log_level ?? self::DEFAULT_LOG_LEVEL)."\n"; + $config .= 'compress-type='.($backup->pgbackrest_compress_type ?? self::DEFAULT_COMPRESS_TYPE)."\n"; + $config .= 'compress-level='.($backup->pgbackrest_compress_level ?? self::DEFAULT_COMPRESS_LEVEL)."\n"; + $config .= "start-fast=y\n"; + $config .= "stop-auto=y\n"; + $config .= "delta=y\n"; + $config .= "process-max=2\n"; + + if ($backup->pgbackrest_archive_mode === 'minimal') { + $config .= "archive-check=n\n"; + } + + $config .= "\n[{$stanza}]\n"; + $config .= 'pg1-path='.self::PGDATA_PATH."\n"; + + $validRepoCount = 0; + foreach ($repos as $repo) { + $repoConfig = self::generateRepoConfig($repo, $database); + if (! empty($repoConfig)) { + $config .= $repoConfig; + $validRepoCount++; + } + } + + if ($validRepoCount === 0) { + return null; + } + + return $config; + } + + public static function generateRepoConfig(PgbackrestRepo $repo, StandalonePostgresql $database): string + { + $repoKey = $repo->getRepoKey(); + $settings = []; + + if ($repo->isS3()) { + $s3 = $repo->s3Storage; + if (! $s3) { + return ''; + } + + try { + validateShellSafePath($s3->bucket, 'S3 bucket'); + validateShellSafePath($s3->endpoint, 'S3 endpoint'); + } catch (\Exception $e) { + throw new \Exception('Invalid S3 configuration: '.$e->getMessage()); + } + + $settings = [ + "{$repoKey}-type" => 's3', + "{$repoKey}-path" => "/{$database->uuid}", + "{$repoKey}-s3-bucket" => $s3->bucket, + "{$repoKey}-s3-endpoint" => self::cleanEndpoint($s3->endpoint), + "{$repoKey}-s3-region" => $s3->region ?: 'us-east-1', + "{$repoKey}-s3-uri-style" => 'path', + ]; + } else { + $repoPath = $repo->getEffectivePath(); + $settings = [ + "{$repoKey}-type" => 'posix', + "{$repoKey}-path" => $repoPath, + ]; + } + + $retentionFull = $repo->retention_full ?: 2; + $retentionDiff = $repo->retention_diff ?: 7; + $retentionFullType = $repo->retention_full_type === 'time' ? 'time' : 'count'; + + $settings["{$repoKey}-retention-full-type"] = $retentionFullType; + $settings["{$repoKey}-retention-full"] = $retentionFull; + $settings["{$repoKey}-retention-diff"] = $retentionDiff; + + return implode("\n", array_map(fn ($k, $v) => "{$k}={$v}", array_keys($settings), $settings))."\n"; + } + + public static function cleanEndpoint(string $endpoint): string + { + $endpoint = preg_replace('#^https?://#', '', $endpoint); + $endpoint = rtrim($endpoint, '/'); + + return $endpoint; + } + + public static function getInstallCommand(): string + { + return <<<'BASH' +if ! command -v pgbackrest &> /dev/null; then + if command -v apk >/dev/null 2>&1; then + echo "Installing pgBackRest via apk..." + apk add --no-cache pgbackrest + elif command -v apt-get >/dev/null 2>&1; then + echo "Installing pgBackRest via apt..." + apt-get update && apt-get install -y --no-install-recommends pgbackrest && rm -rf /var/lib/apt/lists/* + elif command -v yum >/dev/null 2>&1; then + echo "Installing pgBackRest via yum..." + yum install -y pgbackrest + else + echo "ERROR: Could not detect package manager to install pgBackRest" + exit 1 + fi +else + echo "pgBackRest already installed" +fi +BASH; + } + + public static function buildInstallAndSetupCommand(string $command): string + { + $installCmd = self::getInstallCommand(); + $setupCmd = 'mkdir -p /var/lib/pgbackrest/log /tmp/pgbackrest 2>/dev/null || true'; + $clearLocksCmd = 'rm -rf /tmp/pgbackrest/*.lock 2>/dev/null || true'; + $permsCmd = 'chown -R postgres:postgres /var/lib/pgbackrest /etc/pgbackrest /tmp/pgbackrest 2>/dev/null || true'; + + return "{$installCmd}; {$setupCmd}; {$clearLocksCmd}; {$permsCmd}; su postgres -c \"{$command}\""; + } + + public static function buildS3EnvVars(ScheduledDatabaseBackup $backup): array + { + $envVars = []; + $repos = $backup->enabledPgbackrestRepos()->get(); + + foreach ($repos as $repo) { + if ($repo->isS3() && $repo->s3Storage) { + $s3 = $repo->s3Storage; + $repoNum = $repo->repo_number; + $envVars["PGBACKREST_REPO{$repoNum}_S3_KEY"] = $s3->key; + $envVars["PGBACKREST_REPO{$repoNum}_S3_KEY_SECRET"] = $s3->secret; + } + } + + return $envVars; + } + + public static function buildS3EnvVarsForRepo(PgbackrestRepo $repo): array + { + if (! $repo->isS3() || ! $repo->s3Storage) { + return []; + } + + $s3 = $repo->s3Storage; + $repoNum = $repo->repo_number; + + return [ + "PGBACKREST_REPO{$repoNum}_S3_KEY" => $s3->key, + "PGBACKREST_REPO{$repoNum}_S3_KEY_SECRET" => $s3->secret, + ]; + } + + public static function buildBackupCommand( + string $stanza, + string $type = 'full', + ?string $logLevel = null, + ?int $repoNumber = null + ): string { + $cmd = "pgbackrest --stanza={$stanza}"; + + if ($logLevel) { + $cmd .= " --log-level-console={$logLevel}"; + } + + if ($repoNumber !== null) { + $cmd .= " --repo={$repoNumber}"; + } + + $cmd .= " --type={$type} backup"; + + return $cmd; + } + + public static function buildRestoreCommand( + string $stanza, + ?string $label = null, + ?string $targetTime = null, + ?string $logLevel = null, + ?int $repoNumber = null + ): string { + $cmd = "pgbackrest --stanza={$stanza}"; + + if ($logLevel) { + $cmd .= " --log-level-console={$logLevel}"; + } + + if ($repoNumber !== null) { + $cmd .= " --repo={$repoNumber}"; + } + + if ($label) { + $cmd .= " --set={$label}"; + } + + if ($targetTime) { + $cmd .= " --type=time --target=\"{$targetTime}\" --target-action=promote"; + } + + $cmd .= ' restore'; + + return $cmd; + } + + public static function buildInfoCommand(string $stanza, bool $json = true, ?int $repoNumber = null): string + { + $cmd = "pgbackrest --stanza={$stanza}"; + + if ($repoNumber !== null) { + $cmd .= " --repo={$repoNumber}"; + } + + if ($json) { + $cmd .= ' --output=json'; + } + + $cmd .= ' info'; + + return $cmd; + } + + public static function buildStanzaCreateCommand(string $stanza): string + { + return "pgbackrest --stanza={$stanza} --log-level-console=info stanza-create"; + } + + public static function buildExpireCommand( + string $stanza, + ?int $repoNumber = null + ): string { + $cmd = "pgbackrest --stanza={$stanza}"; + + if ($repoNumber !== null) { + $cmd .= " --repo={$repoNumber}"; + } + + $cmd .= ' expire'; + + return $cmd; + } + + public static function parseInfoJson(string $json): ?array + { + $data = json_decode($json, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + return $data; + } + + public static function getLatestBackup(array $info): ?array + { + if (empty($info) || ! isset($info[0]['backup'])) { + return null; + } + + $backups = $info[0]['backup'] ?? []; + + if (empty($backups)) { + return null; + } + + return end($backups); + } + + public static function findBackupByLabel(array $info, string $label): ?array + { + if (empty($info) || ! isset($info[0]['backup'])) { + return null; + } + + foreach ($info[0]['backup'] as $backup) { + if (($backup['label'] ?? '') === $label) { + return $backup; + } + } + + return null; + } + + public static function getBackupSize(array $backup): int + { + return $backup['info']['repository']['size'] ?? 0; + } + + public static function getBackupType(array $backup): string + { + return $backup['type'] ?? 'full'; + } + + public static function stanzaExists(array $info): bool + { + if (empty($info) || ! isset($info[0])) { + return false; + } + + $status = $info[0]['status'] ?? []; + + return ($status['code'] ?? 0) === 0; + } + + public static function hasBackups(array $info): bool + { + if (empty($info) || ! isset($info[0]['backup'])) { + return false; + } + + return count($info[0]['backup']) > 0; + } +} diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5df36db33..795cd6403 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -242,6 +242,10 @@ function deleteEmptyBackupFolder($folderPath, Server $server): void function removeOldBackups($backup): void { try { + if ($backup->isPgBackrest()) { + return; + } + if ($backup->executions) { // Delete old local backups (only if local backup is NOT disabled) // Note: When disable_local_backup is enabled, each execution already marks its own diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 670716164..b8e303351 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3381,3 +3381,19 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon return true; } + +/** + * Convert a path from the SSH target perspective to the Docker host perspective. + * + * In dev mode, SSH commands run in coolify-testing-host where the volume is mounted + * at /data/coolify, but Docker Compose runs on the host where the same volume is at + * /var/lib/docker/volumes/coolify_dev_coolify_data/_data. + */ +function convertPathToDockerHost(string $path): string +{ + if (isDev()) { + return str_replace('/data/coolify', '/var/lib/docker/volumes/coolify_dev_coolify_data/_data', $path); + } + + return $path; +} diff --git a/database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php b/database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php new file mode 100644 index 000000000..c69f3d41d --- /dev/null +++ b/database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php @@ -0,0 +1,82 @@ +string('engine')->default('native')->index()->after('enabled'); + $table->string('pgbackrest_backup_type')->nullable()->after('engine'); + $table->string('pgbackrest_compress_type')->nullable()->after('pgbackrest_backup_type'); + $table->unsignedTinyInteger('pgbackrest_compress_level')->nullable()->after('pgbackrest_compress_type'); + $table->string('pgbackrest_log_level')->nullable()->after('pgbackrest_compress_level'); + $table->string('pgbackrest_archive_mode')->nullable()->after('pgbackrest_log_level'); + }); + + Schema::table('scheduled_database_backup_executions', function (Blueprint $table) { + $table->string('engine')->nullable()->index()->after('status'); + $table->string('pgbackrest_backup_type')->nullable()->after('engine'); + $table->string('pgbackrest_label')->nullable()->after('pgbackrest_backup_type'); + $table->string('pgbackrest_stanza')->nullable()->after('pgbackrest_label'); + $table->unsignedBigInteger('pgbackrest_repo_size')->nullable()->after('pgbackrest_stanza'); + }); + + Schema::create('pgbackrest_repos', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + + $table->foreignId('scheduled_database_backup_id') + ->constrained('scheduled_database_backups') + ->cascadeOnDelete(); + + $table->unsignedTinyInteger('repo_number')->default(1); + + $table->string('type')->default('posix'); + + $table->string('path')->nullable(); + $table->foreignId('s3_storage_id') + ->nullable() + ->constrained('s3_storages') + ->nullOnDelete(); + + $table->string('retention_full_type')->default('count'); + $table->unsignedInteger('retention_full')->default(2); + $table->unsignedInteger('retention_diff')->default(7); + $table->boolean('enabled')->default(true); + + $table->timestamps(); + + $table->unique(['scheduled_database_backup_id', 'repo_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pgbackrest_repos'); + + Schema::table('scheduled_database_backup_executions', function (Blueprint $table) { + $table->dropColumn([ + 'engine', + 'pgbackrest_backup_type', + 'pgbackrest_label', + 'pgbackrest_stanza', + 'pgbackrest_repo_size', + ]); + }); + + Schema::table('scheduled_database_backups', function (Blueprint $table) { + $table->dropColumn([ + 'engine', + 'pgbackrest_backup_type', + 'pgbackrest_compress_type', + 'pgbackrest_compress_level', + 'pgbackrest_log_level', + 'pgbackrest_archive_mode', + ]); + }); + } +}; diff --git a/database/migrations/2025_12_09_231050_create_database_restores_table.php b/database/migrations/2025_12_09_231050_create_database_restores_table.php new file mode 100644 index 000000000..43c3d1a3f --- /dev/null +++ b/database/migrations/2025_12_09_231050_create_database_restores_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('uuid')->unique(); + + // Polymorphic relationship to the database being restored + $table->morphs('database'); + + // Reference to the backup execution being restored from + $table->foreignId('scheduled_database_backup_execution_id') + ->nullable() + ->constrained('scheduled_database_backup_executions') + ->nullOnDelete(); + + // Restore engine and target + $table->string('engine')->default('pgbackrest'); + $table->string('target_label')->nullable(); + $table->timestamp('target_time')->nullable(); + + // Status tracking: pending, running, success, failed + $table->string('status')->default('pending'); + $table->longText('message')->nullable(); + $table->longText('log')->nullable(); + + $table->timestamps(); + $table->timestamp('finished_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('database_restores'); + } +}; diff --git a/openapi.json b/openapi.json index fe8ca863e..3e85d7151 100644 --- a/openapi.json +++ b/openapi.json @@ -3846,6 +3846,60 @@ "database_backup_retention_max_storage_s3": { "type": "integer", "description": "Max storage (MB) for S3 backups" + }, + "engine": { + "type": "string", + "description": "Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)", + "enum": [ + "native", + "pgbackrest" + ], + "default": "native" + }, + "pgbackrest_backup_type": { + "type": "string", + "description": "pgBackRest backup type", + "enum": [ + "full", + "diff", + "incr" + ] + }, + "pgbackrest_compress_type": { + "type": "string", + "description": "pgBackRest compression type", + "enum": [ + "lz4", + "gzip", + "zstd", + "none" + ] + }, + "pgbackrest_compress_level": { + "type": "integer", + "description": "pgBackRest compression level (0-9)" + }, + "pgbackrest_log_level": { + "type": "string", + "description": "pgBackRest log level", + "enum": [ + "off", + "error", + "warn", + "info", + "detail", + "debug", + "trace" + ] + }, + "pgbackrest_archive_mode": { + "type": "string", + "description": "pgBackRest archive mode for WAL archiving", + "enum": [ + "standard", + "reduced", + "minimal" + ] } }, "type": "object" @@ -4413,6 +4467,59 @@ "database_backup_retention_max_storage_s3": { "type": "integer", "description": "Max storage of the backup in S3" + }, + "engine": { + "type": "string", + "description": "Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)", + "enum": [ + "native", + "pgbackrest" + ] + }, + "pgbackrest_backup_type": { + "type": "string", + "description": "pgBackRest backup type", + "enum": [ + "full", + "diff", + "incr" + ] + }, + "pgbackrest_compress_type": { + "type": "string", + "description": "pgBackRest compression type", + "enum": [ + "lz4", + "gzip", + "zstd", + "none" + ] + }, + "pgbackrest_compress_level": { + "type": "integer", + "description": "pgBackRest compression level (0-9)" + }, + "pgbackrest_log_level": { + "type": "string", + "description": "pgBackRest log level", + "enum": [ + "off", + "error", + "warn", + "info", + "detail", + "debug", + "trace" + ] + }, + "pgbackrest_archive_mode": { + "type": "string", + "description": "pgBackRest archive mode for WAL archiving", + "enum": [ + "standard", + "reduced", + "minimal" + ] } }, "type": "object" @@ -5835,6 +5942,186 @@ ] } }, + "\/databases\/{uuid}\/restore": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Restore Database", + "description": "Restore a PostgreSQL database from a PgBackRest backup.", + "operationId": "restore-database", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application\/json": { + "schema": { + "properties": { + "execution_uuid": { + "description": "UUID of backup execution to restore from (optional, uses latest if not specified)", + "type": "string" + }, + "target_time": { + "description": "ISO 8601 timestamp for point-in-time recovery (optional)", + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Restore initiated", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Database restore initiated." + }, + "restore_uuid": { + "type": "string", + "example": "abc123" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/restores": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Restores", + "description": "List all restore operations for a database.", + "operationId": "list-database-restores", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of restores", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/restores\/{restore_uuid}": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Restore Status", + "description": "Get the status of a restore operation.", + "operationId": "get-restore-status", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "restore_uuid", + "in": "path", + "description": "UUID of the restore operation.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restore status", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index a7faa8c72..0108117d9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2434,6 +2434,30 @@ paths: database_backup_retention_max_storage_s3: type: integer description: 'Max storage (MB) for S3 backups' + engine: + type: string + description: 'Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)' + enum: [native, pgbackrest] + default: native + pgbackrest_backup_type: + type: string + description: 'pgBackRest backup type' + enum: [full, diff, incr] + pgbackrest_compress_type: + type: string + description: 'pgBackRest compression type' + enum: [lz4, gzip, zstd, none] + pgbackrest_compress_level: + type: integer + description: 'pgBackRest compression level (0-9)' + pgbackrest_log_level: + type: string + description: 'pgBackRest log level' + enum: ['off', error, warn, info, detail, debug, trace] + pgbackrest_archive_mode: + type: string + description: 'pgBackRest archive mode for WAL archiving' + enum: [standard, reduced, minimal] type: object responses: '201': @@ -2828,6 +2852,29 @@ paths: database_backup_retention_max_storage_s3: type: integer description: 'Max storage of the backup in S3' + engine: + type: string + description: 'Backup engine: native (pg_dump) or pgbackrest (PostgreSQL only)' + enum: [native, pgbackrest] + pgbackrest_backup_type: + type: string + description: 'pgBackRest backup type' + enum: [full, diff, incr] + pgbackrest_compress_type: + type: string + description: 'pgBackRest compression type' + enum: [lz4, gzip, zstd, none] + pgbackrest_compress_level: + type: integer + description: 'pgBackRest compression level (0-9)' + pgbackrest_log_level: + type: string + description: 'pgBackRest log level' + enum: ['off', error, warn, info, detail, debug, trace] + pgbackrest_archive_mode: + type: string + description: 'pgBackRest archive mode for WAL archiving' + enum: [standard, reduced, minimal] type: object responses: '200': @@ -3804,6 +3851,123 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/restore': + post: + tags: + - Databases + summary: 'Restore Database' + description: 'Restore a PostgreSQL database from a PgBackRest backup.' + operationId: restore-database + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + properties: + execution_uuid: + description: 'UUID of backup execution to restore from (optional, uses latest if not specified)' + type: string + target_time: + description: 'ISO 8601 timestamp for point-in-time recovery (optional)' + type: string + type: object + responses: + '200': + description: 'Restore initiated' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Database restore initiated.' } + restore_uuid: { type: string, example: abc123 } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/databases/{uuid}/restores': + get: + tags: + - Databases + summary: 'List Restores' + description: 'List all restore operations for a database.' + operationId: list-database-restores + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 'List of restores' + content: + application/json: + schema: + type: array + items: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/databases/{uuid}/restores/{restore_uuid}': + get: + tags: + - Databases + summary: 'Restore Status' + description: 'Get the status of a restore operation.' + operationId: get-restore-status + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + format: uuid + - + name: restore_uuid + in: path + description: 'UUID of the restore operation.' + required: true + schema: + type: string + responses: + '200': + description: 'Restore status' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /deployments: get: tags: diff --git a/resources/views/emails/database-restore-failed.blade.php b/resources/views/emails/database-restore-failed.blade.php new file mode 100644 index 000000000..d6f4c8f0f --- /dev/null +++ b/resources/views/emails/database-restore-failed.blade.php @@ -0,0 +1,8 @@ + +Database restore for {{ $name }} has failed. +@if($label) +Attempted to restore from backup: {{ $label }} +@endif + +Error: {{ $error }} + diff --git a/resources/views/emails/database-restore-success.blade.php b/resources/views/emails/database-restore-success.blade.php new file mode 100644 index 000000000..2bb9773dd --- /dev/null +++ b/resources/views/emails/database-restore-success.blade.php @@ -0,0 +1,9 @@ + +Database restore for {{ $name }} was successful. +@if($label) +Restored from backup: {{ $label }} +@endif +@if($target_time) +Target time: {{ $target_time }} +@endif + diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index bb5dcfc4d..f11e2ac94 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -20,66 +20,208 @@
- @if ($s3s->count() > 0) - - @else - + @if ($isPostgresql && $backup->database_id !== 0) + @endif - @if ($backup->save_s3) - - @else - + @if ($engine !== 'pgbackrest') + @if ($s3s->count() > 0) + + @else + + @endif + @if ($saveS3) + + @else + + @endif @endif
- @if ($backup->save_s3) + @if ($engine !== 'pgbackrest' && $saveS3)
- + @foreach ($s3s as $s3) @endforeach
@endif -
-

Settings

-
- @if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0) -
- -
- @if (!$backup->dump_all) - + + @if ($engine === 'pgbackrest') + {{-- PgBackRest Settings Panel --}} +
+
+

pgBackRest Settings

+ + Enabled + +
+

+ pgBackRest provides efficient incremental backups with compression, parallel operations, and point-in-time recovery support. +

+ + {{-- Backup Type --}} +
+ + + + + +
+ + {{-- Compression Settings --}} +

Compression

+
+ + + + + + + + +
+ + {{-- Log Level --}} +
+ + + + + + + + + +
+ + {{-- Point-in-Time Recovery Mode --}} +

Point-in-Time Recovery

+
+ + + + + +
+

+ 'Standard' keeps full WAL history for restoring to any moment. 'Minimal' only allows restoring to exact backup points but uses less storage. +

+ + {{-- Repository Configuration --}} +

Backup Repositories

+

+ Configure where backups are stored. You can enable both local and S3 storage for redundancy. +

+ +
+ @if ($s3s->count() > 0) + + @else + @endif - @elseif($backup->database_type === 'App\Models\StandaloneMongodb') - - @elseif($backup->database_type === 'App\Models\StandaloneMysql') -
- -
- @if (!$backup->dump_all) - + @if ($saveS3) + + @else + @endif - @elseif($backup->database_type === 'App\Models\StandaloneMariadb') -
- +
+ + {{-- S3 Repository Settings --}} + @if ($saveS3) +
+

S3 Repository

+
+ + + @foreach ($s3s as $s3) + + @endforeach + +
+ +
+ + + + + + +
+
+ @endif + + {{-- Local Repository Settings --}} + @if (!$disableLocalBackup || !$saveS3) +
+

Local Repository

+
+ + + + + + +
- @if (!$backup->dump_all) - - @endif @endif
+ @endif + +
+

Settings

+ @if ($engine !== 'pgbackrest') +
+ @if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0) +
+ +
+ @if (!$backup->dump_all) + + @endif + @elseif($backup->database_type === 'App\Models\StandaloneMongodb') + + @elseif($backup->database_type === 'App\Models\StandaloneMysql') +
+ +
+ @if (!$backup->dump_all) + + @endif + @elseif($backup->database_type === 'App\Models\StandaloneMariadb') +
+ +
+ @if (!$backup->dump_all) + + @endif + @endif +
+ @endif
-

Backup Retention Settings

-
-
    -
  • Setting a value to 0 means unlimited retention.
  • -
  • The retention rules work independently - whichever limit is reached first will trigger cleanup.
  • -
-
- -
-
-

Local Backup Retention

-
- - - -
+ @if ($engine !== 'pgbackrest') +

Backup Retention Settings

+
+
    +
  • Setting a value to 0 means unlimited retention.
  • +
  • The retention rules work independently - whichever limit is reached first will trigger cleanup.
  • +
- @if ($backup->save_s3) +
-

S3 Storage Retention

+

Local Backup Retention

- - + - + + 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." />
- @endif -
+ + @if ($saveS3) +
+

S3 Storage Retention

+
+ + + +
+
+ @endif +
+ @endif
diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index c56908f50..0e5f1d9cd 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -66,6 +66,15 @@ @endphp {{ $statusText }} + {{-- Show pgBackRest badge if this is a pgBackRest backup --}} + @if (data_get($execution, 'engine') === 'pgbackrest') + + pgBackRest + @if (data_get($execution, 'pgbackrest_backup_type')) + ({{ ucfirst(data_get($execution, 'pgbackrest_backup_type')) }}) + @endif + + @endif
@if (data_get($execution, 'status') === 'running') @@ -82,54 +91,48 @@ • Database: {{ data_get($execution, 'database_name', 'N/A') }} @if(data_get($execution, 'size')) • Size: {{ formatBytes(data_get($execution, 'size')) }} + @elseif(data_get($execution, 'pgbackrest_repo_size')) + • Repo Size: {{ formatBytes(data_get($execution, 'pgbackrest_repo_size')) }} @endif
- Location: {{ data_get($execution, 'filename', 'N/A') }} + @if (data_get($execution, 'engine') === 'pgbackrest' && data_get($execution, 'pgbackrest_label')) + Label: {{ data_get($execution, 'pgbackrest_label') }} + @else + Location: {{ data_get($execution, 'filename', 'N/A') }} + @endif
Backup Availability:
- !data_get( - $execution, - 'local_storage_deleted', - false), - 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get( - $execution, - 'local_storage_deleted', - false), - ])> - - @if (!data_get($execution, 'local_storage_deleted', false)) + @if (data_get($execution, 'engine') === 'pgbackrest') + {{-- For pgBackRest, show repository status instead of local/S3 --}} + + - @else - - - - @endif - Local Storage + pgBackRest Repository + - - @if (data_get($execution, 's3_uploaded') !== null) + @else data_get($execution, 's3_uploaded') === false && !data_get($execution, 's3_storage_deleted', false), - 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false), - 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get($execution, 's3_storage_deleted', false), + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => !data_get( + $execution, + 'local_storage_deleted', + false), + 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get( + $execution, + 'local_storage_deleted', + false), ])> - @if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) + @if (!data_get($execution, 'local_storage_deleted', false)) @endif - S3 Storage + Local Storage + @if (data_get($execution, 's3_uploaded') !== null) + data_get($execution, 's3_uploaded') === false && !data_get($execution, 's3_storage_deleted', false), + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false), + 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get($execution, 's3_storage_deleted', false), + ])> + + @if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) + + + + @else + + + + @endif + S3 Storage + + + @endif @endif
@if (data_get($execution, 'message')) @@ -156,30 +186,54 @@ @endif
@if (data_get($execution, 'status') === 'success') - Download + @if (data_get($execution, 'engine') !== 'pgbackrest') + Download + @endif + {{-- Show Restore button for successful pgBackRest backups --}} + @if (data_get($execution, 'engine') === 'pgbackrest' && data_get($execution, 'pgbackrest_label')) + + @endif @endif @php $executionCheckboxes = []; $deleteActions = []; - if (!data_get($execution, 'local_storage_deleted', false)) { - $deleteActions[] = 'This backup will be permanently deleted from local storage.'; - } + if (data_get($execution, 'engine') === 'pgbackrest') { + $deleteActions[] = 'This will remove the backup entry from this list.'; + $deleteActions[] = 'Note: The actual backup data in pgBackRest is managed by retention policies.'; + } else { + if (!data_get($execution, 'local_storage_deleted', false)) { + $deleteActions[] = 'This backup will be permanently deleted from local storage.'; + } - if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) { - $executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage']; - } + if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) { + $executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage']; + } - if (empty($deleteActions)) { - $deleteActions[] = 'This backup execution record will be deleted.'; + if (empty($deleteActions)) { + $deleteActions[] = 'This backup execution record will be deleted.'; + } } @endphp + :actions="$deleteActions" confirmationText="{{ data_get($execution, 'pgbackrest_label') ?: data_get($execution, 'filename') }}" + confirmationLabel="Please confirm the execution of the actions by entering the Backup {{ data_get($execution, 'engine') === 'pgbackrest' ? 'Label' : 'Filename' }} below" + shortConfirmationLabel="Backup {{ data_get($execution, 'engine') === 'pgbackrest' ? 'Label' : 'Filename' }}" />
@empty @@ -191,5 +245,187 @@ window.open('/download/backup/' + executionId, '_blank'); } + + {{-- Restore Confirmation Modal --}} + @if ($showRestoreModal && $restoreExecutionId) + @php + $restoreExecution = $executions->firstWhere('id', $restoreExecutionId); + @endphp +
+
+
+
+ + + +
+

Restore Database from pgBackRest Backup?

+
+ +
+
+

Backup Label:

+ {{ data_get($restoreExecution, 'pgbackrest_label') }} +
+ +
+

+ + + + This will stop the PostgreSQL database and restore it from the selected backup. +

+

+ + + + All data written after this backup was taken will be permanently lost. +

+

+ + + + The database will be temporarily unavailable during the restore process. +

+

+ + + + After restore, the database will automatically restart. +

+
+
+ +
+ + +
+ +
+ Cancel + + Restore Database + +
+
+
+ @endif + + {{-- Restore Progress Modal --}} + @if ($showRestoreProgressModal && $currentRestore) +
isFinished()) wire:poll.2000ms="pollRestoreStatus" @endif> +
+ {{-- Header with status icon --}} +
+ @if ($currentRestore->isRunning() || $currentRestore->isPending()) +
+ + + + +
+ @elseif ($currentRestore->isSuccess()) +
+ + + +
+ @elseif ($currentRestore->isFailed()) +
+ + + +
+ @endif +
+

pgBackRest Restore

+ $currentRestore->isRunning() || $currentRestore->isPending(), + 'text-green-600 dark:text-green-400' => $currentRestore->isSuccess(), + 'text-red-600 dark:text-red-400' => $currentRestore->isFailed(), + ])> + {{ ucfirst($currentRestore->status) }} + +
+ @if ($currentRestore->isFinished()) + + @endif +
+ + {{-- Progress bar for running state --}} + @if ($currentRestore->isRunning() || $currentRestore->isPending()) +
+
+
+ @endif + + {{-- Backup label --}} + @if ($currentRestore->target_label) +
+

Backup Label:

+ {{ $currentRestore->target_label }} +
+ @endif + + {{-- Status message --}} + @if ($currentRestore->message) +
$currentRestore->isRunning() || $currentRestore->isPending(), + 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800' => $currentRestore->isSuccess(), + 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800' => $currentRestore->isFailed(), + ])> +

{{ $currentRestore->message }}

+
+ @endif + + {{-- Info banner for running state --}} + @if ($currentRestore->isRunning() || $currentRestore->isPending()) +
+

+ + + + The database is temporarily unavailable during restore. This modal will update automatically when the restore completes. +

+
+ @endif + + {{-- Log output (scrollable) --}} + @if ($currentRestore->log && ($currentRestore->isFailed() || $currentRestore->isSuccess())) +
+

Restore Log:

+
+
{{ $currentRestore->log }}
+
+
+ @endif + + {{-- Action buttons --}} +
+ @if ($currentRestore->isFinished()) + + {{ $currentRestore->isSuccess() ? 'Close' : 'Dismiss' }} + + @endif +
+
+
+ @endif @endisset
diff --git a/routes/api.php b/routes/api.php index aaf7d794b..fa62d5f5b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -151,6 +151,10 @@ Route::group([ Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::post('/databases/{uuid}/restore', [DatabasesController::class, 'restore_database'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/restores', [DatabasesController::class, 'list_restores'])->middleware(['api.ability:read']); + Route::get('/databases/{uuid}/restores/{restore_uuid}', [DatabasesController::class, 'restore_status'])->middleware(['api.ability:read']); + Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']); Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index c3addcc82..9a7af8787 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -3953,6 +3953,22 @@ "logo": "svgs/default.webp", "minversion": "0.0.0" }, + "soju": { + "documentation": "https://soju.im/?utm_source=coolify.io", + "slogan": "A user-friendly IRC bouncer with a modern web interface", + "compose": "c2VydmljZXM6CiAgc29qdToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL3NvanU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc29qdS1kYjovZGInCiAgICAgIC0gJ3NvanUtdXBsb2FkczovdXBsb2FkcycKICAgICAgLSAnc29qdS1ydW46L3J1bi9zb2p1JwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9zb2p1L2NvbmZpZwogICAgICAgIHRhcmdldDogL3NvanUtY29uZmlnCiAgICAgICAgY29udGVudDogImRiIHNxbGl0ZTMgL2RiL21haW4uZGJcbm1lc3NhZ2Utc3RvcmUgZGJcbmZpbGUtdXBsb2FkIGZzIC91cGxvYWRzL1xubGlzdGVuIGlyYytpbnNlY3VyZTovLzAuMC4wLjA6NjY2N1xubGlzdGVuIHdzK2luc2VjdXJlOi8vMC4wLjAuMDo4MFxubGlzdGVuIHVuaXgrYWRtaW46Ly8vcnVuL3NvanUvYWRtaW5cbiIKICAgIG5ldHdvcmtzOgogICAgICBkZWZhdWx0OgogICAgICAgIGFsaWFzZXM6CiAgICAgICAgICAtIGdhbWphLWJhY2tlbmQKICBnYW1qYToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL2dhbWphOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9HQU1KQV84MAogICAgZGVwZW5kc19vbjoKICAgICAgLSBzb2p1CnZvbHVtZXM6CiAgc29qdS1kYjogbnVsbAogIHNvanUtdXBsb2FkczogbnVsbAogIHNvanUtcnVuOiBudWxsCg==", + "tags": [ + "irc", + "bouncer", + "chat", + "messaging", + "relay" + ], + "category": "communication", + "logo": "svgs/soju.svg", + "minversion": "0.0.0", + "port": "80" + }, "soketi": { "documentation": "https://docs.soketi.app?utm_source=coolify.io", "slogan": "Soketi is your simple, fast, and resilient open-source WebSockets server.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 5da51e9f0..7791c1750 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -3953,6 +3953,22 @@ "logo": "svgs/default.webp", "minversion": "0.0.0" }, + "soju": { + "documentation": "https://soju.im/?utm_source=coolify.io", + "slogan": "A user-friendly IRC bouncer with a modern web interface", + "compose": "c2VydmljZXM6CiAgc29qdToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL3NvanU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc29qdS1kYjovZGInCiAgICAgIC0gJ3NvanUtdXBsb2FkczovdXBsb2FkcycKICAgICAgLSAnc29qdS1ydW46L3J1bi9zb2p1JwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9zb2p1L2NvbmZpZwogICAgICAgIHRhcmdldDogL3NvanUtY29uZmlnCiAgICAgICAgY29udGVudDogImRiIHNxbGl0ZTMgL2RiL21haW4uZGJcbm1lc3NhZ2Utc3RvcmUgZGJcbmZpbGUtdXBsb2FkIGZzIC91cGxvYWRzL1xubGlzdGVuIGlyYytpbnNlY3VyZTovLzAuMC4wLjA6NjY2N1xubGlzdGVuIHdzK2luc2VjdXJlOi8vMC4wLjAuMDo4MFxubGlzdGVuIHVuaXgrYWRtaW46Ly8vcnVuL3NvanUvYWRtaW5cbiIKICAgIG5ldHdvcmtzOgogICAgICBkZWZhdWx0OgogICAgICAgIGFsaWFzZXM6CiAgICAgICAgICAtIGdhbWphLWJhY2tlbmQKICBnYW1qYToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL2dhbWphOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9HQU1KQV84MAogICAgZGVwZW5kc19vbjoKICAgICAgLSBzb2p1CnZvbHVtZXM6CiAgc29qdS1kYjogbnVsbAogIHNvanUtdXBsb2FkczogbnVsbAogIHNvanUtcnVuOiBudWxsCg==", + "tags": [ + "irc", + "bouncer", + "chat", + "messaging", + "relay" + ], + "category": "communication", + "logo": "svgs/soju.svg", + "minversion": "0.0.0", + "port": "80" + }, "soketi": { "documentation": "https://docs.soketi.app?utm_source=coolify.io", "slogan": "Soketi is your simple, fast, and resilient open-source WebSockets server.", From bc36e929d0778bb8ab148ab21ced0cb25ee394d7 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 10 Dec 2025 02:00:12 +0000 Subject: [PATCH 2/7] CodeRabbit fixes CodeRabbit fixes CodeRabbit fixes Handle case where PGDATA was already empty S3 fix Squash migrations --- app/Actions/Database/PgBackrestRestore.php | 12 +- app/Actions/Database/StartPostgresql.php | 14 +- .../Controllers/Api/DatabasesController.php | 127 +++++++++++------- app/Jobs/PgBackrestRestoreJob.php | 35 +++-- app/Livewire/Project/Database/BackupEdit.php | 49 ++++--- .../Project/Database/BackupExecutions.php | 15 +-- app/Models/DatabaseRestore.php | 9 +- app/Models/ScheduledDatabaseBackup.php | 9 +- app/Services/Backup/PgBackrestService.php | 39 ++++-- ...5_12_09_231049_add_pgbackrest_support.php} | 24 ++++ ..._231050_create_database_restores_table.php | 43 ------ .../project/database/backup-edit.blade.php | 4 +- .../database/backup-executions.blade.php | 63 ++++----- 13 files changed, 243 insertions(+), 200 deletions(-) rename database/migrations/{2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php => 2025_12_09_231049_add_pgbackrest_support.php} (78%) delete mode 100644 database/migrations/2025_12_09_231050_create_database_restores_table.php diff --git a/app/Actions/Database/PgBackrestRestore.php b/app/Actions/Database/PgBackrestRestore.php index 05a78340c..e14aea97f 100644 --- a/app/Actions/Database/PgBackrestRestore.php +++ b/app/Actions/Database/PgBackrestRestore.php @@ -18,18 +18,8 @@ class PgBackrestRestore ?ScheduledDatabaseBackupExecution $execution = null, ?string $targetTime = null ): DatabaseRestore { - $attempts = 0; - do { - $uuid = (string) new Cuid2; - $exists = DatabaseRestore::where('uuid', $uuid)->exists(); - $attempts++; - if ($attempts >= 3 && $exists) { - throw new \Exception('Unable to generate unique UUID for restore after 3 attempts'); - } - } while ($exists); - $restore = DatabaseRestore::create([ - 'uuid' => $uuid, + 'uuid' => (string) new Cuid2, 'database_id' => $database->id, 'database_type' => $database->getMorphClass(), 'engine' => 'pgbackrest', diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index e2a3f4df8..1b441499f 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -338,7 +338,11 @@ class StartPostgresql $content = $this->database->postgres_conf ?? ''; if (! str($content)->contains('listen_addresses')) { - $content .= "\nlisten_addresses = '*'"; + if ($this->database->is_public) { + $content .= "\nlisten_addresses = '*'"; + } else { + $content .= "\nlisten_addresses = 'localhost'"; + } } if ($this->hasPgBackrest) { @@ -350,11 +354,9 @@ class StartPostgresql $content .= "\narchive_mode = on"; } - if (str($content)->contains('archive_command')) { - $content = preg_replace("/archive_command\s*=\s*'[^']*'/", "archive_command = '{$archiveCommand}'", $content); - } else { - $content .= "\narchive_command = '{$archiveCommand}'"; - } + $content = preg_replace('/^\s*archive_command\s*=.*$/m', '', $content); + $content = preg_replace('/\n{3,}/', "\n\n", $content); + $content .= "\narchive_command = '{$archiveCommand}'"; if (! str($content)->contains('wal_level')) { $content .= "\nwal_level = replica"; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 64be5ea47..da007c581 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -47,6 +47,51 @@ class DatabasesController extends Controller return serializeApiResponse($database); } + /** + * Allowed fields for backup configuration API requests. + */ + private function getBackupConfigFields(): array + { + return [ + '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', + 'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level', + 'pgbackrest_log_level', 'pgbackrest_archive_mode', + ]; + } + + /** + * Build backup configuration data from request input. + * Converts s3_storage_uuid to s3_storage_id and filters to allowed fields. + * + * @return array{data: array, error: \Illuminate\Http\JsonResponse|null} + */ + private function buildBackupConfig(Request $request, bool $requireSaveS3 = false): array + { + $backupData = $request->only($this->getBackupConfigFields()); + + if (isset($backupData['s3_storage_uuid'])) { + $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + if ($s3Storage) { + $backupData['s3_storage_id'] = $s3Storage->id; + } elseif ($requireSaveS3 || $request->boolean('save_s3')) { + return [ + 'data' => [], + 'error' => response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422), + ]; + } + unset($backupData['s3_storage_uuid']); + } + + return ['data' => $backupData, 'error' => null]; + } + #[OA\Get( summary: 'List', description: 'List all databases.', @@ -685,22 +730,13 @@ 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', - 'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level', - 'pgbackrest_log_level', 'pgbackrest_archive_mode', - ]; + $backupConfigFields = $this->getBackupConfigFields(); $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - // Validate incoming request is valid JSON $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -796,21 +832,11 @@ class DatabasesController extends Controller ], 422); } - $backupData = $request->only($backupConfigFields); - - // Convert s3_storage_uuid to s3_storage_id - if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); - if ($s3Storage) { - $backupData['s3_storage_id'] = $s3Storage->id; - } elseif ($request->boolean('save_s3')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - unset($backupData['s3_storage_uuid']); + $result = $this->buildBackupConfig($request); + if ($result['error']) { + return $result['error']; } + $backupData = $result['data']; // Set default databases_to_backup based on database type if not provided if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) { @@ -933,25 +959,18 @@ 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', - 'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level', - 'pgbackrest_log_level', 'pgbackrest_archive_mode', - ]; + $backupConfigFields = $this->getBackupConfigFields(); $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - // this check if the request is a valid json + $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } + $validator = customApiValidator($request->all(), [ 'save_s3' => 'boolean', 'backup_now' => 'boolean|nullable', @@ -973,6 +992,7 @@ class DatabasesController extends Controller 'pgbackrest_log_level' => 'string|in:off,error,warn,info,detail,debug,trace|nullable', 'pgbackrest_archive_mode' => 'string|in:standard,reduced,minimal|nullable', ]); + if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -984,7 +1004,6 @@ class DatabasesController extends Controller return response()->json(['message' => 'UUID is required.'], 404); } - // Validate scheduled_backup_uuid is provided if (! $request->scheduled_backup_uuid) { return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); } @@ -1012,6 +1031,7 @@ class DatabasesController extends Controller 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], ], 422); } + if ($request->filled('s3_storage_uuid')) { $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); if (! $existsInTeam) { @@ -1042,21 +1062,11 @@ class DatabasesController extends Controller ], 422); } - $backupData = $request->only($backupConfigFields); - - // Convert s3_storage_uuid to s3_storage_id - if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); - if ($s3Storage) { - $backupData['s3_storage_id'] = $s3Storage->id; - } elseif ($request->boolean('save_s3')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - unset($backupData['s3_storage_uuid']); + $result = $this->buildBackupConfig($request); + if ($result['error']) { + return $result['error']; } + $backupData = $result['data']; $backupConfig->update($backupData); @@ -2883,6 +2893,18 @@ class DatabasesController extends Controller return response()->json(['message' => 'UUID is required.'], 400); } + $validator = customApiValidator($request->all(), [ + 'execution_uuid' => 'string|uuid|nullable', + 'target_time' => 'date|nullable', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); if (! $database) { return response()->json(['message' => 'Database not found.'], 404); @@ -2899,7 +2921,7 @@ class DatabasesController extends Controller } $execution = null; - if ($request->has('execution_uuid')) { + if ($request->filled('execution_uuid')) { $execution = ScheduledDatabaseBackupExecution::where('uuid', $request->execution_uuid) ->whereHas('scheduledDatabaseBackup', function ($query) use ($database) { $query->where('database_id', $database->id) @@ -2914,7 +2936,10 @@ class DatabasesController extends Controller } } - $targetTime = $request->input('target_time'); + $targetTime = null; + if ($request->filled('target_time')) { + $targetTime = \Carbon\Carbon::parse($request->input('target_time'))->toIso8601String(); + } $restore = PgBackrestRestore::run($database, $execution, $targetTime); diff --git a/app/Jobs/PgBackrestRestoreJob.php b/app/Jobs/PgBackrestRestoreJob.php index e2906a510..c6ce39b01 100644 --- a/app/Jobs/PgBackrestRestoreJob.php +++ b/app/Jobs/PgBackrestRestoreJob.php @@ -67,9 +67,10 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $this->restore->appendLog('Starting PostgreSQL after restore.'); StartPostgresql::run($this->database); - // Restore succeeded, safely remove the backup - $this->restore->appendLog('Removing backup of previous database.'); - $this->removePgDataBackup($backupPath); + if ($backupPath) { + $this->restore->appendLog('Removing backup of previous database.'); + $this->removePgDataBackup($backupPath); + } $this->restore->updateStatus('success', 'PgBackRest restore completed successfully.'); @@ -82,9 +83,10 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue )); } } catch (Throwable $restoreError) { - // Restore failed, attempt to recover $this->restore->appendLog('Restore failed, attempting to recover from backup: '.$restoreError->getMessage()); - $this->recoverFromBackup($backupPath); + if ($backupPath) { + $this->recoverFromBackup($backupPath); + } throw $restoreError; } } catch (Throwable $e) { @@ -237,7 +239,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue return $info; } - private function backupCurrentPgData(int $timestamp): string + private function backupCurrentPgData(int $timestamp): ?string { $server = $this->database->destination->server; $pgdataVolume = $this->database->pgdataVolume(); @@ -247,10 +249,27 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue } $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; + + $checkCmd = "docker run --rm -v {$mount}:/data alpine sh -c 'test -n \"$(ls -A /data 2>/dev/null)\" && echo OK || echo EMPTY'"; + $checkResult = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true); + + if (trim($checkResult) === 'EMPTY') { + $this->restore->appendLog('No existing PGDATA to backup (directory is empty).'); + + return null; + } + $backupPath = "{$mount}_backup_{$timestamp}"; - $backupCmd = "docker run --rm -v {$mount}:/data -v {$mount}_backup_{$timestamp}:/backup alpine sh -c 'cp -a /data/* /backup/ 2>/dev/null || true'"; - instant_remote_process([$backupCmd], $server, false, false, 600, disableMultiplexing: true); + $backupCmd = "docker run --rm -v {$mount}:/data -v {$mount}_backup_{$timestamp}:/backup alpine sh -c 'cp -a /data/. /backup/'"; + instant_remote_process([$backupCmd], $server, true, false, 600, disableMultiplexing: true); + + $verifyCmd = "docker run --rm -v {$backupPath}:/backup alpine sh -c 'test -n \"$(ls -A /backup 2>/dev/null)\" && echo OK'"; + $result = instant_remote_process([$verifyCmd], $server, false, false, 30, disableMultiplexing: true); + + if (trim($result) !== 'OK') { + throw new \RuntimeException('PGDATA backup verification failed: backup directory is empty or inaccessible.'); + } $this->restore->appendLog("PGDATA backed up to temporary location: {$backupPath}"); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 7b1b35417..775c09736 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -122,6 +122,19 @@ class BackupEdit extends Component #[Validate(['nullable', 'integer', 'min:1'])] public ?int $s3RepoRetentionDiff = 7; + public function getShowLocalRepoSettingsProperty(): bool + { + return ! $this->disableLocalBackup || ! $this->saveS3; + } + + /** + * Toggle between pgBackRest and native backup engines. + */ + public function togglePgbackrestEngine(): void + { + $this->engine = $this->engine === 'pgbackrest' ? 'native' : 'pgbackrest'; + } + public function mount() { try { @@ -140,6 +153,8 @@ class BackupEdit extends Component $this->backup->frequency = $this->frequency; $this->backup->engine = $this->engine; + $this->backup->timeout = $this->timeout; + if ($this->engine === 'pgbackrest') { $this->backup->save_s3 = $this->saveS3; $this->backup->disable_local_backup = $this->saveS3 && $this->disableLocalBackup; @@ -150,6 +165,7 @@ class BackupEdit extends Component $this->backup->pgbackrest_log_level = $this->pgbackrestLogLevel; $this->backup->pgbackrest_archive_mode = $this->pgbackrestArchiveMode; + $this->customValidate(); $this->backup->save(); $this->syncPgbackrestRepos(); } else { @@ -169,9 +185,9 @@ class BackupEdit extends Component $dbName = trim($db); try { validateShellSafePath($dbName, 'database name'); - } catch (\Exception $e) { + } catch (Exception $e) { $position = $index + 1; - throw new \Exception( + throw new Exception( "Database #{$position} ('{$dbName}') validation failed: ". $e->getMessage() ); @@ -181,12 +197,10 @@ class BackupEdit extends Component $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; + + $this->customValidate(); $this->backup->save(); } - - $this->backup->timeout = $this->timeout; - $this->customValidate(); - $this->backup->save(); } else { $this->backupEnabled = $this->backup->enabled; $this->frequency = $this->backup->frequency; @@ -245,19 +259,24 @@ class BackupEdit extends Component private function syncPgbackrestRepos(): void { $hasLocal = ! $this->disableLocalBackup; + + if ($this->saveS3 && empty($this->s3RepoStorageId)) { + if ($this->s3s->isNotEmpty()) { + $this->s3RepoStorageId = $this->s3s->first()->id; + } else { + throw new Exception('S3 storage must be selected when S3 backups are enabled.'); + } + } + $hasS3 = $this->saveS3 && ! empty($this->s3RepoStorageId); if (! $hasLocal && ! $hasS3) { - throw new \Exception( + throw new Exception( 'At least one backup repository (local or S3) must be enabled for pgBackRest. '. 'Either enable local backups or configure S3 storage and enable it.' ); } - if ($hasS3 && empty($this->s3RepoStorageId)) { - throw new \Exception('S3 storage must be selected when S3 backups are enabled.'); - } - $repoNumber = 1; if ($hasLocal) { @@ -279,10 +298,6 @@ class BackupEdit extends Component } if ($hasS3) { - if (empty($this->s3RepoStorageId)) { - throw new \Exception('S3 Storage is required when enabling S3 backups for pgBackRest.'); - } - $s3Repo = $this->backup->s3Repo(); if (! $s3Repo) { $s3Repo = new PgbackrestRepo; @@ -352,7 +367,7 @@ class BackupEdit extends Component } else { return redirect()->route('project.database.backup.index', $this->parameters); } - } catch (\Exception $e) { + } catch (Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); return handleError($e, $this); @@ -383,7 +398,7 @@ class BackupEdit extends Component $isValid = validate_cron_expression($this->backup->frequency); if (! $isValid) { - throw new \Exception('Invalid Cron / Human expression'); + throw new Exception('Invalid Cron / Human expression'); } $this->validate(); } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index ad3f8c3fe..af6148dfb 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -127,7 +127,7 @@ class BackupExecutions extends Component public function confirmRestore(int $executionId) { - $execution = ScheduledDatabaseBackupExecution::find($executionId); + $execution = $this->backup->executions()->where('id', $executionId)->first(); if (! $execution || ! $execution->canRestore()) { $this->dispatch('error', 'This backup cannot be restored.'); @@ -144,17 +144,9 @@ class BackupExecutions extends Component $this->showRestoreModal = false; } - public function startRestore(int $executionId, $password = null, $selectedActions = []) + public function startRestore(int $executionId) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! $password || ! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } - } - - $execution = ScheduledDatabaseBackupExecution::find($executionId); + $execution = $this->backup->executions()->where('id', $executionId)->first(); if (! $execution || ! $execution->canRestore()) { $this->dispatch('error', 'This backup cannot be restored.'); @@ -268,6 +260,7 @@ class BackupExecutions extends Component { $this->backup = $backup; $this->database = $backup->database; + $this->authorize('view', $this->database); $this->updateCurrentPage(); $this->loadExecutions(); } diff --git a/app/Models/DatabaseRestore.php b/app/Models/DatabaseRestore.php index f5945ead7..417d008a7 100644 --- a/app/Models/DatabaseRestore.php +++ b/app/Models/DatabaseRestore.php @@ -52,11 +52,14 @@ class DatabaseRestore extends BaseModel return in_array($this->status, ['success', 'failed']); } - public function appendLog(string $message): void + public function appendLog(string $message, bool $persist = true): void { $timestamp = now()->format('Y-m-d H:i:s'); $this->log = ($this->log ?? '')."[{$timestamp}] {$message}\n"; - $this->save(); + + if ($persist) { + $this->save(); + } } public function updateStatus(string $status, ?string $message = null): void @@ -65,7 +68,7 @@ class DatabaseRestore extends BaseModel if ($message !== null) { $this->message = $message; - $this->appendLog($message); + $this->appendLog($message, persist: false); } if ($this->isFinished()) { diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 28315f34f..7a6e1c46a 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -43,12 +44,14 @@ class ScheduledDatabaseBackup extends BaseModel public function localRepo(): ?PgbackrestRepo { - return $this->pgbackrestRepos()->where('type', 'posix')->first(); + return $this->enabledPgbackrestRepos()->where('type', 'posix')->first() + ?? $this->pgbackrestRepos()->where('type', 'posix')->first(); } public function s3Repo(): ?PgbackrestRepo { - return $this->pgbackrestRepos()->where('type', 's3')->first(); + return $this->enabledPgbackrestRepos()->where('type', 's3')->first() + ?? $this->pgbackrestRepos()->where('type', 's3')->first(); } public function hasLocalRepo(): bool @@ -61,7 +64,7 @@ class ScheduledDatabaseBackup extends BaseModel return $this->pgbackrestRepos()->where('type', 's3')->where('enabled', true)->exists(); } - public function restores(): HasMany + public function restores(): HasManyThrough { return $this->hasManyThrough( DatabaseRestore::class, diff --git a/app/Services/Backup/PgBackrestService.php b/app/Services/Backup/PgBackrestService.php index 09708da4d..b940a2dd2 100644 --- a/app/Services/Backup/PgBackrestService.php +++ b/app/Services/Backup/PgBackrestService.php @@ -198,17 +198,20 @@ BASH; ?string $logLevel = null, ?int $repoNumber = null ): string { - $cmd = "pgbackrest --stanza={$stanza}"; + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; if ($logLevel) { - $cmd .= " --log-level-console={$logLevel}"; + $escapedLogLevel = escapeshellarg($logLevel); + $cmd .= " --log-level-console={$escapedLogLevel}"; } if ($repoNumber !== null) { - $cmd .= " --repo={$repoNumber}"; + $cmd .= ' --repo='.((int) $repoNumber); } - $cmd .= " --type={$type} backup"; + $escapedType = escapeshellarg($type); + $cmd .= " --type={$escapedType} backup"; return $cmd; } @@ -220,22 +223,26 @@ BASH; ?string $logLevel = null, ?int $repoNumber = null ): string { - $cmd = "pgbackrest --stanza={$stanza}"; + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; if ($logLevel) { - $cmd .= " --log-level-console={$logLevel}"; + $escapedLogLevel = escapeshellarg($logLevel); + $cmd .= " --log-level-console={$escapedLogLevel}"; } if ($repoNumber !== null) { - $cmd .= " --repo={$repoNumber}"; + $cmd .= ' --repo='.((int) $repoNumber); } if ($label) { - $cmd .= " --set={$label}"; + $escapedLabel = escapeshellarg($label); + $cmd .= " --set={$escapedLabel}"; } if ($targetTime) { - $cmd .= " --type=time --target=\"{$targetTime}\" --target-action=promote"; + $escapedTargetTime = escapeshellarg($targetTime); + $cmd .= " --type=time --target={$escapedTargetTime} --target-action=promote"; } $cmd .= ' restore'; @@ -245,10 +252,11 @@ BASH; public static function buildInfoCommand(string $stanza, bool $json = true, ?int $repoNumber = null): string { - $cmd = "pgbackrest --stanza={$stanza}"; + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; if ($repoNumber !== null) { - $cmd .= " --repo={$repoNumber}"; + $cmd .= ' --repo='.((int) $repoNumber); } if ($json) { @@ -262,17 +270,20 @@ BASH; public static function buildStanzaCreateCommand(string $stanza): string { - return "pgbackrest --stanza={$stanza} --log-level-console=info stanza-create"; + $escapedStanza = escapeshellarg($stanza); + + return "pgbackrest --stanza={$escapedStanza} --log-level-console=info stanza-create"; } public static function buildExpireCommand( string $stanza, ?int $repoNumber = null ): string { - $cmd = "pgbackrest --stanza={$stanza}"; + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; if ($repoNumber !== null) { - $cmd .= " --repo={$repoNumber}"; + $cmd .= ' --repo='.((int) $repoNumber); } $cmd .= ' expire'; diff --git a/database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php b/database/migrations/2025_12_09_231049_add_pgbackrest_support.php similarity index 78% rename from database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php rename to database/migrations/2025_12_09_231049_add_pgbackrest_support.php index c69f3d41d..615eeee6f 100644 --- a/database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php +++ b/database/migrations/2025_12_09_231049_add_pgbackrest_support.php @@ -52,10 +52,34 @@ return new class extends Migration $table->unique(['scheduled_database_backup_id', 'repo_number']); }); + + Schema::create('database_restores', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + + $table->morphs('database'); + + $table->foreignId('scheduled_database_backup_execution_id') + ->nullable() + ->constrained('scheduled_database_backup_executions') + ->nullOnDelete(); + + $table->string('engine')->default('pgbackrest'); + $table->string('target_label')->nullable(); + $table->timestamp('target_time')->nullable(); + + $table->string('status')->default('pending'); + $table->longText('message')->nullable(); + $table->longText('log')->nullable(); + + $table->timestamps(); + $table->timestamp('finished_at')->nullable(); + }); } public function down(): void { + Schema::dropIfExists('database_restores'); Schema::dropIfExists('pgbackrest_repos'); Schema::table('scheduled_database_backup_executions', function (Blueprint $table) { diff --git a/database/migrations/2025_12_09_231050_create_database_restores_table.php b/database/migrations/2025_12_09_231050_create_database_restores_table.php deleted file mode 100644 index 43c3d1a3f..000000000 --- a/database/migrations/2025_12_09_231050_create_database_restores_table.php +++ /dev/null @@ -1,43 +0,0 @@ -id(); - $table->string('uuid')->unique(); - - // Polymorphic relationship to the database being restored - $table->morphs('database'); - - // Reference to the backup execution being restored from - $table->foreignId('scheduled_database_backup_execution_id') - ->nullable() - ->constrained('scheduled_database_backup_executions') - ->nullOnDelete(); - - // Restore engine and target - $table->string('engine')->default('pgbackrest'); - $table->string('target_label')->nullable(); - $table->timestamp('target_time')->nullable(); - - // Status tracking: pending, running, success, failed - $table->string('status')->default('pending'); - $table->longText('message')->nullable(); - $table->longText('log')->nullable(); - - $table->timestamps(); - $table->timestamp('finished_at')->nullable(); - }); - } - - public function down(): void - { - Schema::dropIfExists('database_restores'); - } -}; diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index f11e2ac94..858b2fc14 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -24,7 +24,7 @@ @endif @if ($engine !== 'pgbackrest') @@ -166,7 +166,7 @@ @endif {{-- Local Repository Settings --}} - @if (!$disableLocalBackup || !$saveS3) + @if ($this->showLocalRepoSettings)

Local Repository

diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index 0e5f1d9cd..6e110a026 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -192,20 +192,22 @@ @endif {{-- Show Restore button for successful pgBackRest backups --}} @if (data_get($execution, 'engine') === 'pgbackrest' && data_get($execution, 'pgbackrest_label')) - + @can('manage', $database) + + @endcan @endif @endif @php @@ -296,24 +298,23 @@
-
- - -
+
+
+ + +
-
- Cancel - - Restore Database - +
+ Cancel + + Restore Database + +
From 0834a79fb26993d3653ba2cc41933f89146efdf3 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 10 Dec 2025 17:35:19 +0000 Subject: [PATCH 3/7] coderabbit fixes, batch 2 --- app/Actions/Database/PgBackrestRestore.php | 1 + app/Jobs/PgBackrestRestoreJob.php | 45 +++++++++---------- app/Livewire/Project/Database/BackupEdit.php | 3 +- ...25_12_09_231049_add_pgbackrest_support.php | 4 ++ .../database/backup-executions.blade.php | 2 + 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/app/Actions/Database/PgBackrestRestore.php b/app/Actions/Database/PgBackrestRestore.php index e14aea97f..6081d6064 100644 --- a/app/Actions/Database/PgBackrestRestore.php +++ b/app/Actions/Database/PgBackrestRestore.php @@ -38,6 +38,7 @@ class PgBackrestRestore { return [ 'database' => ['required'], + 'targetTime' => ['nullable', 'date'], ]; } } diff --git a/app/Jobs/PgBackrestRestoreJob.php b/app/Jobs/PgBackrestRestoreJob.php index c6ce39b01..d43a32969 100644 --- a/app/Jobs/PgBackrestRestoreJob.php +++ b/app/Jobs/PgBackrestRestoreJob.php @@ -17,6 +17,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; +use RuntimeException; use Throwable; class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue @@ -38,8 +39,6 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { - $server = $this->database->destination->server; - $containerName = $this->database->uuid; $stanza = PgBackrestService::getStanzaName($this->database); $backupTimestamp = time(); @@ -124,54 +123,54 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $this->restore->appendLog('Running pre-flight validation.'); if (! $this->database->hasPgBackrestBackups()) { - throw new \RuntimeException('No PgBackRest backup configuration found for this database.'); + throw new RuntimeException('No PgBackRest backup configuration found for this database.'); } $backup = $this->database->pgbackrestBackups()->where('enabled', true)->first(); if (! $backup) { - throw new \RuntimeException('No enabled PgBackRest backup configuration found.'); + throw new RuntimeException('No enabled PgBackRest backup configuration found.'); } $s3Repos = $backup->pgbackrestRepos()->where('type', 's3')->where('enabled', true)->get(); foreach ($s3Repos as $s3Repo) { $s3 = $s3Repo->s3Storage; if (! $s3) { - throw new \RuntimeException("S3 storage configuration not found for repo {$s3Repo->repo_number}."); + throw new RuntimeException("S3 storage configuration not found for repo {$s3Repo->repo_number}."); } try { $s3->testConnection(shouldSave: true); } catch (Throwable $e) { - throw new \RuntimeException("S3 connection test failed for repo {$s3Repo->repo_number}: ".$e->getMessage()); + throw new RuntimeException("S3 connection test failed for repo {$s3Repo->repo_number}: ".$e->getMessage()); } } $pgdataVolume = $this->database->pgdataVolume(); if (! $pgdataVolume) { - throw new \RuntimeException('PGDATA volume not found.'); + throw new RuntimeException('PGDATA volume not found.'); } $repoVolume = $this->database->pgbackrestRepoVolume(); $hasLocalRepo = $backup->hasLocalRepo(); if (! $repoVolume && $hasLocalRepo) { - throw new \RuntimeException('PgBackRest repository volume not found.'); + throw new RuntimeException('PgBackRest repository volume not found.'); } $this->restore->appendLog('Verifying backup exists in repository via sidecar container.'); $info = $this->runInfoSidecar($stanza, $backup); if (! PgBackrestService::stanzaExists($info)) { - throw new \RuntimeException('PgBackRest stanza does not exist or is not healthy.'); + throw new RuntimeException('PgBackRest stanza does not exist or is not healthy.'); } if (! PgBackrestService::hasBackups($info)) { - throw new \RuntimeException('No backups found in PgBackRest repository.'); + throw new RuntimeException('No backups found in PgBackRest repository.'); } if ($this->execution && $this->execution->pgbackrest_label) { $targetBackup = PgBackrestService::findBackupByLabel($info, $this->execution->pgbackrest_label); if (! $targetBackup) { - throw new \RuntimeException("Backup with label '{$this->execution->pgbackrest_label}' not found in repository."); + throw new RuntimeException("Backup with label '{$this->execution->pgbackrest_label}' not found in repository."); } $this->restore->appendLog("Target backup verified: {$this->execution->pgbackrest_label}"); } else { @@ -203,8 +202,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); foreach ($s3EnvVars as $key => $value) { - $escapedValue = addslashes($value); - $envPieces[] = "-e {$key}=\"{$escapedValue}\""; + $envPieces[] = '-e '.$key.'='.escapeshellarg($value); } $infoCmd = PgBackrestService::buildInfoCommand($stanza, true); @@ -223,7 +221,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $output = instant_remote_process([$cmd], $server, false, false, 120, disableMultiplexing: true); if ($output === null || $output === '') { - throw new \RuntimeException('Failed to get PgBackRest info - command returned no output. Command: '.$cmd); + throw new RuntimeException('Failed to get PgBackRest info - command returned no output. Command: '.$cmd); } $jsonStart = strpos($output, '['); @@ -233,7 +231,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $info = PgBackrestService::parseInfoJson($output); if ($info === null) { - throw new \RuntimeException('Failed to parse PgBackRest info output: '.$output); + throw new RuntimeException('Failed to parse PgBackRest info output: '.$output); } return $info; @@ -245,7 +243,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $pgdataVolume = $this->database->pgdataVolume(); if (! $pgdataVolume) { - throw new \RuntimeException('PGDATA volume not found.'); + throw new RuntimeException('PGDATA volume not found.'); } $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; @@ -268,7 +266,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $result = instant_remote_process([$verifyCmd], $server, false, false, 30, disableMultiplexing: true); if (trim($result) !== 'OK') { - throw new \RuntimeException('PGDATA backup verification failed: backup directory is empty or inaccessible.'); + throw new RuntimeException('PGDATA backup verification failed: backup directory is empty or inaccessible.'); } $this->restore->appendLog("PGDATA backed up to temporary location: {$backupPath}"); @@ -320,7 +318,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $pgdataVolume = $this->database->pgdataVolume(); if (! $pgdataVolume) { - throw new \RuntimeException('PGDATA volume not found.'); + throw new RuntimeException('PGDATA volume not found.'); } $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; @@ -336,7 +334,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue try { $pgdataVolume = $this->database->pgdataVolume(); if (! $pgdataVolume) { - throw new \RuntimeException('PGDATA volume not found.'); + throw new RuntimeException('PGDATA volume not found.'); } $server = $this->database->destination->server; @@ -347,12 +345,12 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $result = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true); if (trim($result) !== 'OK') { - throw new \RuntimeException('Restored PGDATA does not contain valid PostgreSQL data (PG_VERSION not found).'); + throw new RuntimeException('Restored PGDATA does not contain valid PostgreSQL data (PG_VERSION not found).'); } $this->restore->appendLog('Restored database verified successfully.'); } catch (Throwable $e) { - throw new \RuntimeException('Database verification failed: '.$e->getMessage()); + throw new RuntimeException('Database verification failed: '.$e->getMessage()); } } @@ -364,7 +362,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $backup = $this->database->pgbackrestBackups()->where('enabled', true)->first(); if (! $backup) { - throw new \RuntimeException('No enabled PgBackRest backup configuration found.'); + throw new RuntimeException('No enabled PgBackRest backup configuration found.'); } $pgdataVolume = $this->database->pgdataVolume(); @@ -386,8 +384,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); foreach ($s3EnvVars as $key => $value) { - $escapedValue = addslashes($value); - $envPieces[] = "-e {$key}=\"{$escapedValue}\""; + $envPieces[] = '-e '.$key.'='.escapeshellarg($value); } $restoreCmd = PgBackrestService::buildRestoreCommand( diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 775c09736..275be6547 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\Database; use App\Models\InstanceSettings; use App\Models\PgbackrestRepo; use App\Models\ScheduledDatabaseBackup; +use App\Models\StandalonePostgresql; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -417,7 +418,7 @@ class BackupEdit extends Component public function isPostgresql(): bool { - return $this->backup->database_type === 'App\Models\StandalonePostgresql'; + return $this->backup->database_type === StandalonePostgresql::class; } public function render() diff --git a/database/migrations/2025_12_09_231049_add_pgbackrest_support.php b/database/migrations/2025_12_09_231049_add_pgbackrest_support.php index 615eeee6f..78be380ff 100644 --- a/database/migrations/2025_12_09_231049_add_pgbackrest_support.php +++ b/database/migrations/2025_12_09_231049_add_pgbackrest_support.php @@ -69,6 +69,7 @@ return new class extends Migration $table->timestamp('target_time')->nullable(); $table->string('status')->default('pending'); + $table->index('status'); $table->longText('message')->nullable(); $table->longText('log')->nullable(); @@ -79,6 +80,9 @@ return new class extends Migration public function down(): void { + Schema::table('database_restores', function (Blueprint $table) { + $table->dropIndex(['status']); + }); Schema::dropIfExists('database_restores'); Schema::dropIfExists('pgbackrest_repos'); diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index 6e110a026..534da1790 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -249,6 +249,7 @@ {{-- Restore Confirmation Modal --}} + @can('manage', $database) @if ($showRestoreModal && $restoreExecutionId) @php $restoreExecution = $executions->firstWhere('id', $restoreExecutionId); @@ -319,6 +320,7 @@ @endif + @endcan {{-- Restore Progress Modal --}} @if ($showRestoreProgressModal && $currentRestore) From d97ba72f34466dfe9c0d05ada7c79abc08aa5f6b Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 10 Dec 2025 17:43:46 +0000 Subject: [PATCH 4/7] Remove the useless UUID checks! --- app/Jobs/DatabaseBackupJob.php | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 533090770..f8817576c 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -298,16 +298,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $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); + $this->backup_log_uuid = (string) new Cuid2; $size = 0; $localBackupSucceeded = false; @@ -696,15 +687,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $backupType = $this->backup->pgbackrest_backup_type ?: 'full'; $this->container_name = $this->database->uuid; - $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); + $this->backup_log_uuid = (string) new Cuid2; $this->backup_log = ScheduledDatabaseBackupExecution::create([ 'uuid' => $this->backup_log_uuid, From 556ced24b524ef67fcbd80cf8c860d307020a359 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Wed, 10 Dec 2025 19:38:32 +0000 Subject: [PATCH 5/7] Make sh*t work when WD isn't 'coolify' in dev + rename, not copy, old PGData Rename, not copy, old PGDATA Bit more reliability when working dir isn't called coolify! --- app/Jobs/DatabaseBackupJob.php | 92 ++++++++--------------- app/Jobs/PgBackrestRestoreJob.php | 40 +++++----- app/Services/Backup/PgBackrestService.php | 45 +++++++++++ bootstrap/helpers/shared.php | 35 ++++++++- 4 files changed, 131 insertions(+), 81 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index f8817576c..0d6b348fe 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -471,7 +471,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.'); } } - \Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); + 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')) { @@ -703,18 +703,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->update_pgbackrest_config(); $backupCmd = PgBackrestService::buildBackupCommand($stanza, $backupType, 'info'); + $backupCmdWithWait = PgBackrestService::wrapWithLockWait($backupCmd); $s3EnvVars = PgBackrestService::buildS3EnvVars($this->backup); - if (! empty($s3EnvVars)) { - $envExport = ''; - foreach ($s3EnvVars as $key => $value) { - $escapedValue = addslashes($value); - $envExport .= "export {$key}=\"{$escapedValue}\"; "; - } - $backupFullCmd = "docker exec {$this->container_name} sh -c '{$envExport} {$backupCmd} 2>&1; echo \"EXIT_CODE:\$?\"'"; - } else { - $backupFullCmd = "docker exec {$this->container_name} sh -c '{$backupCmd} 2>&1; echo \"EXIT_CODE:\$?\"'"; - } + $dockerEnvArgs = PgBackrestService::buildDockerEnvArgs($s3EnvVars); + $fixPermsCmd = 'chown -R postgres:postgres /var/lib/pgbackrest /tmp/pgbackrest /var/log/pgbackrest 2>/dev/null || true'; + $escapedBackupCmd = escapeshellarg($backupCmdWithWait); + $containerName = escapeshellarg($this->container_name); + $backupFullCmd = "docker exec{$dockerEnvArgs} {$containerName} sh -c '{$fixPermsCmd}; su postgres -c {$escapedBackupCmd} 2>&1; echo \"EXIT_CODE:\$?\"'"; $rawOutput = instant_remote_process([$backupFullCmd], $this->server, false, false, $this->timeout, disableMultiplexing: true); $rawOutput = trim($rawOutput) ?: ''; @@ -733,31 +729,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } $infoCmd = PgBackrestService::buildInfoCommand($stanza, true); - - if (! empty($s3EnvVars)) { - $envExport = ''; - foreach ($s3EnvVars as $key => $value) { - $escapedValue = addslashes($value); - $envExport .= "export {$key}=\"{$escapedValue}\"; "; - } - $infoJson = instant_remote_process( - ["docker exec {$this->container_name} sh -c '{$envExport} {$infoCmd}'"], - $this->server, - false, - false, - 120, - disableMultiplexing: true - ); - } else { - $infoJson = instant_remote_process( - ["docker exec {$this->container_name} {$infoCmd}"], - $this->server, - false, - false, - 120, - disableMultiplexing: true - ); - } + $escapedInfoCmd = escapeshellarg($infoCmd); + $infoJson = instant_remote_process( + ["docker exec{$dockerEnvArgs} {$containerName} su postgres -c {$escapedInfoCmd}"], + $this->server, + false, + false, + 120, + disableMultiplexing: true + ); $info = PgBackrestService::parseInfoJson($infoJson); $latestBackup = $info ? PgBackrestService::getLatestBackup($info) : null; @@ -812,35 +792,22 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { try { $repos = $this->backup->enabledPgbackrestRepos()->get(); + $s3EnvVars = PgBackrestService::buildS3EnvVars($this->backup); + $dockerEnvArgs = PgBackrestService::buildDockerEnvArgs($s3EnvVars); + $containerName = escapeshellarg($this->container_name); foreach ($repos as $repo) { $expireCmd = PgBackrestService::buildExpireCommand($stanza, $repo->repo_number); + $escapedExpireCmd = escapeshellarg($expireCmd); - $s3EnvVars = PgBackrestService::buildS3EnvVars($this->backup); - if (! empty($s3EnvVars)) { - $envExport = ''; - foreach ($s3EnvVars as $key => $value) { - $escapedValue = addslashes($value); - $envExport .= "export {$key}=\"{$escapedValue}\"; "; - } - instant_remote_process( - ["docker exec {$this->container_name} sh -c '{$envExport} {$expireCmd}'"], - $this->server, - false, - false, - $this->timeout, - disableMultiplexing: true - ); - } else { - instant_remote_process( - ["docker exec {$this->container_name} {$expireCmd}"], - $this->server, - false, - false, - $this->timeout, - disableMultiplexing: true - ); - } + instant_remote_process( + ["docker exec{$dockerEnvArgs} {$containerName} sh -c {$escapedExpireCmd}"], + $this->server, + false, + false, + $this->timeout, + disableMultiplexing: true + ); } } catch (Throwable $e) { Log::warning('PgBackRest expire failed', [ @@ -860,9 +827,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $configBase64 = base64_encode($config); $configPath = PgBackrestService::CONFIG_PATH; + $containerName = escapeshellarg($this->container_name); instant_remote_process([ - "docker exec {$this->container_name} sh -c 'echo {$configBase64} | base64 -d > {$configPath}/pgbackrest.conf'", + "docker exec {$containerName} sh -c 'echo {$configBase64} | base64 -d > {$configPath}/pgbackrest.conf'", ], $this->server, true, false, 60, disableMultiplexing: true); } diff --git a/app/Jobs/PgBackrestRestoreJob.php b/app/Jobs/PgBackrestRestoreJob.php index d43a32969..23683c3db 100644 --- a/app/Jobs/PgBackrestRestoreJob.php +++ b/app/Jobs/PgBackrestRestoreJob.php @@ -246,9 +246,10 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue throw new RuntimeException('PGDATA volume not found.'); } - $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; + $volumeName = $pgdataVolume->host_path ?: $pgdataVolume->name; + $backupVolumeName = "{$pgdataVolume->name}_backup_{$timestamp}"; - $checkCmd = "docker run --rm -v {$mount}:/data alpine sh -c 'test -n \"$(ls -A /data 2>/dev/null)\" && echo OK || echo EMPTY'"; + $checkCmd = "docker run --rm -v {$volumeName}:/data alpine sh -c 'test -n \"$(ls -A /data 2>/dev/null)\" && echo OK || echo EMPTY'"; $checkResult = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true); if (trim($checkResult) === 'EMPTY') { @@ -257,24 +258,24 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue return null; } - $backupPath = "{$mount}_backup_{$timestamp}"; + instant_remote_process(["docker volume create {$backupVolumeName}"], $server, false, false, 30, disableMultiplexing: true); - $backupCmd = "docker run --rm -v {$mount}:/data -v {$mount}_backup_{$timestamp}:/backup alpine sh -c 'cp -a /data/. /backup/'"; - instant_remote_process([$backupCmd], $server, true, false, 600, disableMultiplexing: true); + $copyCmd = "docker run --rm -v {$volumeName}:/source:ro -v {$backupVolumeName}:/backup alpine sh -c 'cp -a /source/. /backup/'"; + instant_remote_process([$copyCmd], $server, true, false, 300, disableMultiplexing: true); - $verifyCmd = "docker run --rm -v {$backupPath}:/backup alpine sh -c 'test -n \"$(ls -A /backup 2>/dev/null)\" && echo OK'"; + $verifyCmd = "docker run --rm -v {$backupVolumeName}:/backup alpine sh -c 'test -n \"$(ls -A /backup 2>/dev/null)\" && echo OK'"; $result = instant_remote_process([$verifyCmd], $server, false, false, 30, disableMultiplexing: true); if (trim($result) !== 'OK') { - throw new RuntimeException('PGDATA backup verification failed: backup directory is empty or inaccessible.'); + throw new RuntimeException('PGDATA backup verification failed: backup volume is empty or inaccessible.'); } - $this->restore->appendLog("PGDATA backed up to temporary location: {$backupPath}"); + $this->restore->appendLog("PGDATA backed up to volume: {$backupVolumeName}"); - return $backupPath; + return $backupVolumeName; } - private function recoverFromBackup(string $backupPath): void + private function recoverFromBackup(string $backupVolumeName): void { try { $server = $this->database->destination->server; @@ -286,11 +287,15 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue return; } - $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; + $volumeName = $pgdataVolume->host_path ?: $pgdataVolume->name; $this->restore->appendLog('Recovering PGDATA from backup...'); - $recoverCmd = "docker run --rm -v {$mount}:/data -v {$backupPath}:/backup alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true && cp -a /backup/* /data/ 2>/dev/null || true'"; - instant_remote_process([$recoverCmd], $server, false, false, 600, disableMultiplexing: true); + + $clearCmd = "docker run --rm -v {$volumeName}:/data alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true'"; + instant_remote_process([$clearCmd], $server, false, false, 60, disableMultiplexing: true); + + $copyCmd = "docker run --rm -v {$backupVolumeName}:/source:ro -v {$volumeName}:/data alpine sh -c 'cp -a /source/. /data/'"; + instant_remote_process([$copyCmd], $server, true, false, 300, disableMultiplexing: true); $this->restore->appendLog('PGDATA recovered from backup.'); } catch (Throwable $e) { @@ -298,17 +303,16 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue } } - private function removePgDataBackup(string $backupPath): void + private function removePgDataBackup(string $backupVolumeName): void { try { $server = $this->database->destination->server; - $rmCmd = "docker run --rm -v {$backupPath}:/backup alpine sh -c 'rm -rf /backup/* /backup/.[!.]* /backup/..?* 2>/dev/null || true'"; - instant_remote_process([$rmCmd], $server, false, false, 300, disableMultiplexing: true); + instant_remote_process(["docker volume rm {$backupVolumeName} 2>/dev/null || true"], $server, false, false, 60, disableMultiplexing: true); - $this->restore->appendLog('Temporary backup removed.'); + $this->restore->appendLog('Temporary backup volume removed.'); } catch (Throwable $e) { - $this->restore->appendLog('Warning: Failed to remove temporary backup: '.$e->getMessage()); + $this->restore->appendLog('Warning: Failed to remove temporary backup volume: '.$e->getMessage()); } } diff --git a/app/Services/Backup/PgBackrestService.php b/app/Services/Backup/PgBackrestService.php index b940a2dd2..60630db84 100644 --- a/app/Services/Backup/PgBackrestService.php +++ b/app/Services/Backup/PgBackrestService.php @@ -192,6 +192,19 @@ BASH; ]; } + public static function buildDockerEnvArgs(array $envVars): string + { + $args = ''; + foreach ($envVars as $key => $value) { + if (! preg_match('/^[A-Z_][A-Z0-9_]*$/i', $key)) { + throw new \InvalidArgumentException("Invalid environment variable name: {$key}"); + } + $args .= ' -e '.escapeshellarg("{$key}={$value}"); + } + + return $args; + } + public static function buildBackupCommand( string $stanza, string $type = 'full', @@ -216,6 +229,38 @@ BASH; return $cmd; } + public static function wrapWithLockWait(string $command, int $maxWaitSeconds = 900, int $intervalSeconds = 10): string + { + if ($intervalSeconds <= 0) { + throw new \InvalidArgumentException('Interval seconds must be greater than 0'); + } + if ($maxWaitSeconds <= 0) { + throw new \InvalidArgumentException('Max wait seconds must be greater than 0'); + } + + $maxAttempts = (int) ceil($maxWaitSeconds / $intervalSeconds); + + return << Date: Wed, 10 Dec 2025 19:48:27 +0000 Subject: [PATCH 6/7] CodeRabbit fixes, again. --- app/Jobs/DatabaseBackupJob.php | 3 +- app/Jobs/PgBackrestRestoreJob.php | 2 +- app/Livewire/Project/Database/BackupEdit.php | 31 +++++++++++++------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 0d6b348fe..ba53effa7 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -26,6 +26,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use RuntimeException; use Throwable; use Visus\Cuid2\Cuid2; @@ -822,7 +823,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $config = PgBackrestService::generateConfig($this->database); if ($config === null) { - throw new \Exception('No valid pgBackRest repository configured. Please configure at least one repository (local or S3).'); + throw new RuntimeException('No valid pgBackRest repository configured. Please configure at least one repository (local or S3).'); } $configBase64 = base64_encode($config); diff --git a/app/Jobs/PgBackrestRestoreJob.php b/app/Jobs/PgBackrestRestoreJob.php index 23683c3db..602cb261c 100644 --- a/app/Jobs/PgBackrestRestoreJob.php +++ b/app/Jobs/PgBackrestRestoreJob.php @@ -400,7 +400,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $sidecarName = 'pgbackrest-restore-'.$this->database->uuid.'-'.time(); - $fullRestoreScript = $this->getInstallAndRunCommand($restoreCmd).' && chown -R 999:999 '.PgBackrestService::PGDATA_PATH; + $fullRestoreScript = $this->getInstallAndRunCommand($restoreCmd); $cmd = sprintf( 'docker run --rm --name %s --network %s %s %s %s sh -c \'%s\' 2>&1', diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 275be6547..6cede88dc 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -9,6 +9,7 @@ use App\Models\StandalonePostgresql; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -133,6 +134,14 @@ class BackupEdit extends Component */ public function togglePgbackrestEngine(): void { + $this->authorize('update', $this->backup->database); + + if (! $this->isPostgresql()) { + $this->engine = 'native'; + + return; + } + $this->engine = $this->engine === 'pgbackrest' ? 'native' : 'pgbackrest'; } @@ -157,18 +166,20 @@ class BackupEdit extends Component $this->backup->timeout = $this->timeout; if ($this->engine === 'pgbackrest') { - $this->backup->save_s3 = $this->saveS3; - $this->backup->disable_local_backup = $this->saveS3 && $this->disableLocalBackup; + DB::transaction(function () { + $this->backup->save_s3 = $this->saveS3; + $this->backup->disable_local_backup = $this->saveS3 && $this->disableLocalBackup; - $this->backup->pgbackrest_backup_type = $this->pgbackrestBackupType; - $this->backup->pgbackrest_compress_type = $this->pgbackrestCompressType; - $this->backup->pgbackrest_compress_level = $this->pgbackrestCompressLevel; - $this->backup->pgbackrest_log_level = $this->pgbackrestLogLevel; - $this->backup->pgbackrest_archive_mode = $this->pgbackrestArchiveMode; + $this->backup->pgbackrest_backup_type = $this->pgbackrestBackupType; + $this->backup->pgbackrest_compress_type = $this->pgbackrestCompressType; + $this->backup->pgbackrest_compress_level = $this->pgbackrestCompressLevel; + $this->backup->pgbackrest_log_level = $this->pgbackrestLogLevel; + $this->backup->pgbackrest_archive_mode = $this->pgbackrestArchiveMode; - $this->customValidate(); - $this->backup->save(); - $this->syncPgbackrestRepos(); + $this->customValidate(); + $this->backup->save(); + $this->syncPgbackrestRepos(); + }); } else { $this->backup->save_s3 = $this->saveS3; $this->backup->disable_local_backup = $this->disableLocalBackup; From f046a4dda62b59b41f9f5b78938747400ea4f969 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 11 Dec 2025 14:50:06 +0000 Subject: [PATCH 7/7] Fixes! --- app/Jobs/DatabaseBackupJob.php | 30 ++++++--- app/Jobs/PgBackrestRestoreJob.php | 64 +++++++++++--------- app/Livewire/Project/Database/BackupEdit.php | 4 +- 3 files changed, 58 insertions(+), 40 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index ba53effa7..7a46e1d02 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -636,31 +636,38 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } $fullImageName = $this->getFullImageName(); + $escapedNetwork = escapeshellarg($network); + $escapedContainerName = escapeshellarg("backup-of-{$this->backup_log_uuid}"); + $escapedImageName = escapeshellarg($fullImageName); $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); if (filled($containerExists)) { - instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); + instant_remote_process(["docker rm -f {$escapedContainerName}"], $this->server, false, false, null, disableMultiplexing: true); } if (isDev()) { if ($this->database->name === 'coolify-db') { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $escapedVolumeMount = escapeshellarg($backup_location_from.':'.$this->backup_location.':ro'); + $commands[] = "docker run -d --network {$escapedNetwork} --name {$escapedContainerName} --rm -v {$escapedVolumeMount} {$escapedImageName}"; } else { $backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file; - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}"; + $escapedVolumeMount = escapeshellarg($backup_location_from.':'.$this->backup_location.':ro'); + $commands[] = "docker run -d --network {$escapedNetwork} --name {$escapedContainerName} --rm -v {$escapedVolumeMount} {$escapedImageName}"; } } else { - $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; + $escapedVolumeMount = escapeshellarg($this->backup_location.':'.$this->backup_location.':ro'); + $commands[] = "docker run -d --network {$escapedNetwork} --name {$escapedContainerName} --rm -v {$escapedVolumeMount} {$escapedImageName}"; } - // Escape S3 credentials to prevent command injection $escapedEndpoint = escapeshellarg($endpoint); $escapedKey = escapeshellarg($key); $escapedSecret = escapeshellarg($secret); + $escapedBucketPath = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); + $escapedBackupLocation = escapeshellarg($this->backup_location); - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; + $commands[] = "docker exec {$escapedContainerName} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; + $commands[] = "docker exec {$escapedContainerName} mc cp {$escapedBackupLocation} {$escapedBucketPath}"; instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $this->s3_uploaded = true; @@ -669,7 +676,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->add_to_error_output($e->getMessage()); throw $e; } finally { - $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; + $escapedContainerNameForCleanup = escapeshellarg("backup-of-{$this->backup_log_uuid}"); + $command = "docker rm -f {$escapedContainerNameForCleanup}"; instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); } } @@ -709,9 +717,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $s3EnvVars = PgBackrestService::buildS3EnvVars($this->backup); $dockerEnvArgs = PgBackrestService::buildDockerEnvArgs($s3EnvVars); $fixPermsCmd = 'chown -R postgres:postgres /var/lib/pgbackrest /tmp/pgbackrest /var/log/pgbackrest 2>/dev/null || true'; - $escapedBackupCmd = escapeshellarg($backupCmdWithWait); + $escapedInnerCmd = str_replace("'", "'\"'\"'", $backupCmdWithWait); + $fullScript = "{$fixPermsCmd}; su postgres -c '{$escapedInnerCmd}' 2>&1; echo \"EXIT_CODE:\$?\""; + $escapedScript = escapeshellarg($fullScript); $containerName = escapeshellarg($this->container_name); - $backupFullCmd = "docker exec{$dockerEnvArgs} {$containerName} sh -c '{$fixPermsCmd}; su postgres -c {$escapedBackupCmd} 2>&1; echo \"EXIT_CODE:\$?\"'"; + $backupFullCmd = "docker exec{$dockerEnvArgs} {$containerName} sh -c {$escapedScript}"; $rawOutput = instant_remote_process([$backupFullCmd], $this->server, false, false, $this->timeout, disableMultiplexing: true); $rawOutput = trim($rawOutput) ?: ''; diff --git a/app/Jobs/PgBackrestRestoreJob.php b/app/Jobs/PgBackrestRestoreJob.php index 602cb261c..9bb6e0dc3 100644 --- a/app/Jobs/PgBackrestRestoreJob.php +++ b/app/Jobs/PgBackrestRestoreJob.php @@ -195,10 +195,10 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $repoVolume = $this->database->pgbackrestRepoVolume(); if ($repoVolume) { $repoMount = $repoVolume->host_path ?: $repoVolume->name; - $mounts[] = "-v {$repoMount}:/var/lib/pgbackrest"; + $mounts[] = '-v '.escapeshellarg($repoMount.':/var/lib/pgbackrest'); } - $mounts[] = "-v {$configDir}:/etc/pgbackrest:ro"; + $mounts[] = '-v '.escapeshellarg($configDir.':/etc/pgbackrest:ro'); $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); foreach ($s3EnvVars as $key => $value) { @@ -209,13 +209,13 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $sidecarName = 'pgbackrest-info-'.$this->database->uuid.'-'.time(); $cmd = sprintf( - 'docker run --rm --name %s --network %s %s %s %s sh -c \'%s\' 2>&1', - $sidecarName, - $network, + 'docker run --rm --name %s --network %s %s %s %s sh -c %s 2>&1', + escapeshellarg($sidecarName), + escapeshellarg($network), implode(' ', $envPieces), implode(' ', $mounts), - $this->getSidecarImage(), - $this->getInstallAndRunCommand($infoCmd) + escapeshellarg($this->getSidecarImage()), + escapeshellarg($this->getInstallAndRunCommand($infoCmd)) ); $output = instant_remote_process([$cmd], $server, false, false, 120, disableMultiplexing: true); @@ -249,7 +249,8 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $volumeName = $pgdataVolume->host_path ?: $pgdataVolume->name; $backupVolumeName = "{$pgdataVolume->name}_backup_{$timestamp}"; - $checkCmd = "docker run --rm -v {$volumeName}:/data alpine sh -c 'test -n \"$(ls -A /data 2>/dev/null)\" && echo OK || echo EMPTY'"; + $sourceMount = escapeshellarg($volumeName.':/data'); + $checkCmd = 'docker run --rm -v '.$sourceMount." alpine sh -c 'test -n \"\$(ls -A /data 2>/dev/null)\" && echo OK || echo EMPTY'"; $checkResult = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true); if (trim($checkResult) === 'EMPTY') { @@ -258,12 +259,14 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue return null; } - instant_remote_process(["docker volume create {$backupVolumeName}"], $server, false, false, 30, disableMultiplexing: true); + instant_remote_process(['docker volume create '.escapeshellarg($backupVolumeName)], $server, false, false, 30, disableMultiplexing: true); - $copyCmd = "docker run --rm -v {$volumeName}:/source:ro -v {$backupVolumeName}:/backup alpine sh -c 'cp -a /source/. /backup/'"; - instant_remote_process([$copyCmd], $server, true, false, 300, disableMultiplexing: true); + $sourceReadOnlyMount = escapeshellarg($volumeName.':/source:ro'); + $backupMount = escapeshellarg($backupVolumeName.':/backup'); + $copyCmd = 'docker run --rm -v '.$sourceReadOnlyMount.' -v '.$backupMount." alpine sh -c 'cp -a /source/. /backup/'"; + instant_remote_process([$copyCmd], $server, true, false, $this->timeout, disableMultiplexing: true); - $verifyCmd = "docker run --rm -v {$backupVolumeName}:/backup alpine sh -c 'test -n \"$(ls -A /backup 2>/dev/null)\" && echo OK'"; + $verifyCmd = 'docker run --rm -v '.$backupMount." alpine sh -c 'test -n \"\$(ls -A /backup 2>/dev/null)\" && echo OK'"; $result = instant_remote_process([$verifyCmd], $server, false, false, 30, disableMultiplexing: true); if (trim($result) !== 'OK') { @@ -291,11 +294,13 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $this->restore->appendLog('Recovering PGDATA from backup...'); - $clearCmd = "docker run --rm -v {$volumeName}:/data alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true'"; + $dataMount = escapeshellarg($volumeName.':/data'); + $clearCmd = 'docker run --rm -v '.$dataMount." alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true'"; instant_remote_process([$clearCmd], $server, false, false, 60, disableMultiplexing: true); - $copyCmd = "docker run --rm -v {$backupVolumeName}:/source:ro -v {$volumeName}:/data alpine sh -c 'cp -a /source/. /data/'"; - instant_remote_process([$copyCmd], $server, true, false, 300, disableMultiplexing: true); + $sourceMount = escapeshellarg($backupVolumeName.':/source:ro'); + $copyCmd = 'docker run --rm -v '.$sourceMount.' -v '.$dataMount." alpine sh -c 'cp -a /source/. /data/'"; + instant_remote_process([$copyCmd], $server, true, false, $this->timeout, disableMultiplexing: true); $this->restore->appendLog('PGDATA recovered from backup.'); } catch (Throwable $e) { @@ -308,7 +313,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue try { $server = $this->database->destination->server; - instant_remote_process(["docker volume rm {$backupVolumeName} 2>/dev/null || true"], $server, false, false, 60, disableMultiplexing: true); + instant_remote_process(['docker volume rm '.escapeshellarg($backupVolumeName).' 2>/dev/null || true'], $server, false, false, 60, disableMultiplexing: true); $this->restore->appendLog('Temporary backup volume removed.'); } catch (Throwable $e) { @@ -327,8 +332,9 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; - $rmCmd = "docker run --rm -v {$mount}:/data alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true'"; - instant_remote_process([$rmCmd], $server, false, false, 300, disableMultiplexing: true); + $dataMount = escapeshellarg($mount.':/data'); + $rmCmd = 'docker run --rm -v '.$dataMount." alpine sh -c 'rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true'"; + instant_remote_process([$rmCmd], $server, false, false, $this->timeout, disableMultiplexing: true); $this->restore->appendLog('PGDATA directory cleared.'); } @@ -344,8 +350,8 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $server = $this->database->destination->server; $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; - // Verify PG_VERSION exists in restored data - $checkCmd = "docker run --rm -v {$mount}:/data alpine test -f /data/PG_VERSION && echo 'OK' || echo 'FAIL'"; + $dataMount = escapeshellarg($mount.':/data'); + $checkCmd = 'docker run --rm -v '.$dataMount." alpine test -f /data/PG_VERSION && echo 'OK' || echo 'FAIL'"; $result = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true); if (trim($result) !== 'OK') { @@ -375,16 +381,16 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $pgdataMount = $pgdataVolume->host_path ?: $pgdataVolume->name; $mounts = []; - $mounts[] = "-v {$pgdataMount}:".PgBackrestService::PGDATA_PATH; + $mounts[] = '-v '.escapeshellarg($pgdataMount.':'.PgBackrestService::PGDATA_PATH); if ($repoVolume) { $repoMount = $repoVolume->host_path ?: $repoVolume->name; - $mounts[] = "-v {$repoMount}:/var/lib/pgbackrest"; + $mounts[] = '-v '.escapeshellarg($repoMount.':/var/lib/pgbackrest'); } - $mounts[] = "-v {$configDir}:/etc/pgbackrest:ro"; + $mounts[] = '-v '.escapeshellarg($configDir.':/etc/pgbackrest:ro'); - $envPieces = ['-e PGBACKREST_PG1_PATH='.PgBackrestService::PGDATA_PATH]; + $envPieces = ['-e PGBACKREST_PG1_PATH='.escapeshellarg(PgBackrestService::PGDATA_PATH)]; $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); foreach ($s3EnvVars as $key => $value) { @@ -403,13 +409,13 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $fullRestoreScript = $this->getInstallAndRunCommand($restoreCmd); $cmd = sprintf( - 'docker run --rm --name %s --network %s %s %s %s sh -c \'%s\' 2>&1', - $sidecarName, - $network, + 'docker run --rm --name %s --network %s %s %s %s sh -c %s 2>&1', + escapeshellarg($sidecarName), + escapeshellarg($network), implode(' ', $envPieces), implode(' ', $mounts), - $this->getSidecarImage(), - $fullRestoreScript + escapeshellarg($this->getSidecarImage()), + escapeshellarg($fullRestoreScript) ); $output = instant_remote_process([$cmd], $server, true, false, $this->timeout, disableMultiplexing: true); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 6cede88dc..8ffdf7f93 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -168,7 +168,9 @@ class BackupEdit extends Component if ($this->engine === 'pgbackrest') { DB::transaction(function () { $this->backup->save_s3 = $this->saveS3; - $this->backup->disable_local_backup = $this->saveS3 && $this->disableLocalBackup; + $computedDisableLocal = $this->saveS3 && $this->disableLocalBackup; + $this->disableLocalBackup = $computedDisableLocal; + $this->backup->disable_local_backup = $computedDisableLocal; $this->backup->pgbackrest_backup_type = $this->pgbackrestBackupType; $this->backup->pgbackrest_compress_type = $this->pgbackrestCompressType;