diff --git a/app/Actions/Database/PgBackrestRestore.php b/app/Actions/Database/PgBackrestRestore.php new file mode 100644 index 000000000..6081d6064 --- /dev/null +++ b/app/Actions/Database/PgBackrestRestore.php @@ -0,0 +1,44 @@ + (string) new Cuid2, + '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'], + 'targetTime' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 41e39c811..1b441499f 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,134 @@ 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')) { + if ($this->database->is_public) { + $content .= "\nlisten_addresses = '*'"; + } else { + $content .= "\nlisten_addresses = 'localhost'"; + } + } + + 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"; + } + + $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"; + } + 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 15d182db2..cbf385976 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; @@ -44,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.', @@ -636,6 +684,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']], ], ), ) @@ -672,14 +726,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']; + $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; @@ -699,6 +752,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()) { @@ -720,6 +779,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) { @@ -761,21 +828,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'])) { @@ -861,6 +918,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']], ], ), ) @@ -890,17 +953,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']; + $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', @@ -915,7 +979,14 @@ 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([ 'message' => 'Validation failed.', @@ -927,7 +998,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); } @@ -941,12 +1011,21 @@ 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.', '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) { @@ -977,21 +1056,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); @@ -2739,4 +2808,277 @@ 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); + } + + $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); + } + + 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->filled('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 = null; + if ($request->filled('target_time')) { + $targetTime = \Carbon\Carbon::parse($request->input('target_time'))->toIso8601String(); + } + + $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 5fc9f6cd8..42dc93383 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; @@ -25,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; @@ -119,6 +121,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 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; @@ -296,16 +305,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; @@ -642,31 +642,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; @@ -675,7 +682,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); } } @@ -688,6 +696,161 @@ 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; + + $this->backup_log_uuid = (string) new Cuid2; + + $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'); + $backupCmdWithWait = PgBackrestService::wrapWithLockWait($backupCmd); + + $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'; + $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 {$escapedScript}"; + + $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); + $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; + + $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(); + $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); + + 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', [ + 'stanza' => $stanza, + 'error' => $e->getMessage(), + ]); + } + } + + private function update_pgbackrest_config(): void + { + $config = PgBackrestService::generateConfig($this->database); + + if ($config === null) { + throw new RuntimeException('No valid pgBackRest repository configured. Please configure at least one repository (local or S3).'); + } + + $configBase64 = base64_encode($config); + $configPath = PgBackrestService::CONFIG_PATH; + $containerName = escapeshellarg($this->container_name); + + instant_remote_process([ + "docker exec {$containerName} 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..9bb6e0dc3 --- /dev/null +++ b/app/Jobs/PgBackrestRestoreJob.php @@ -0,0 +1,465 @@ +onQueue('high'); + } + + public function handle(): void + { + $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); + + if ($backupPath) { + $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) { + $this->restore->appendLog('Restore failed, attempting to recover from backup: '.$restoreError->getMessage()); + if ($backupPath) { + $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 '.escapeshellarg($repoMount.':/var/lib/pgbackrest'); + } + + $mounts[] = '-v '.escapeshellarg($configDir.':/etc/pgbackrest:ro'); + + $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); + foreach ($s3EnvVars as $key => $value) { + $envPieces[] = '-e '.$key.'='.escapeshellarg($value); + } + + $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', + escapeshellarg($sidecarName), + escapeshellarg($network), + implode(' ', $envPieces), + implode(' ', $mounts), + escapeshellarg($this->getSidecarImage()), + escapeshellarg($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.'); + } + + $volumeName = $pgdataVolume->host_path ?: $pgdataVolume->name; + $backupVolumeName = "{$pgdataVolume->name}_backup_{$timestamp}"; + + $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') { + $this->restore->appendLog('No existing PGDATA to backup (directory is empty).'); + + return null; + } + + instant_remote_process(['docker volume create '.escapeshellarg($backupVolumeName)], $server, false, false, 30, 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 '.$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') { + throw new RuntimeException('PGDATA backup verification failed: backup volume is empty or inaccessible.'); + } + + $this->restore->appendLog("PGDATA backed up to volume: {$backupVolumeName}"); + + return $backupVolumeName; + } + + private function recoverFromBackup(string $backupVolumeName): 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; + } + + $volumeName = $pgdataVolume->host_path ?: $pgdataVolume->name; + + $this->restore->appendLog('Recovering PGDATA from backup...'); + + $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); + + $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) { + $this->restore->appendLog('Failed to recover from backup: '.$e->getMessage()); + } + } + + private function removePgDataBackup(string $backupVolumeName): void + { + try { + $server = $this->database->destination->server; + + 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) { + $this->restore->appendLog('Warning: Failed to remove temporary backup volume: '.$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; + + $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.'); + } + + 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; + + $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') { + 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 '.escapeshellarg($pgdataMount.':'.PgBackrestService::PGDATA_PATH); + + if ($repoVolume) { + $repoMount = $repoVolume->host_path ?: $repoVolume->name; + $mounts[] = '-v '.escapeshellarg($repoMount.':/var/lib/pgbackrest'); + } + + $mounts[] = '-v '.escapeshellarg($configDir.':/etc/pgbackrest:ro'); + + $envPieces = ['-e PGBACKREST_PG1_PATH='.escapeshellarg(PgBackrestService::PGDATA_PATH)]; + + $s3EnvVars = PgBackrestService::buildS3EnvVars($backup); + foreach ($s3EnvVars as $key => $value) { + $envPieces[] = '-e '.$key.'='.escapeshellarg($value); + } + + $restoreCmd = PgBackrestService::buildRestoreCommand( + $stanza, + $this->execution?->pgbackrest_label, + $this->targetTime, + 'info' + ); + + $sidecarName = 'pgbackrest-restore-'.$this->database->uuid.'-'.time(); + + $fullRestoreScript = $this->getInstallAndRunCommand($restoreCmd); + + $cmd = sprintf( + 'docker run --rm --name %s --network %s %s %s %s sh -c %s 2>&1', + escapeshellarg($sidecarName), + escapeshellarg($network), + implode(' ', $envPieces), + implode(' ', $mounts), + escapeshellarg($this->getSidecarImage()), + escapeshellarg($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 35262d7b0..da0112500 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -2,9 +2,15 @@ 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; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -67,7 +73,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; @@ -78,6 +84,66 @@ 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 getShowLocalRepoSettingsProperty(): bool + { + return ! $this->disableLocalBackup || ! $this->saveS3; + } + + /** + * Toggle between pgBackRest and native backup engines. + */ + public function togglePgbackrestEngine(): void + { + $this->authorize('update', $this->backup->database); + + if (! $this->isPostgresql()) { + $this->engine = 'native'; + + return; + } + + $this->engine = $this->engine === 'pgbackrest' ? 'native' : 'pgbackrest'; + } + public function mount() { try { @@ -94,51 +160,86 @@ 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() - ); + $this->backup->timeout = $this->timeout; + + if ($this->engine === 'pgbackrest') { + DB::transaction(function () { + $this->backup->save_s3 = $this->saveS3; + $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; + $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(); + }); + } 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->timeout = $this->timeout; - $this->customValidate(); - $this->backup->save(); + $this->backup->databases_to_backup = $this->databasesToBackup; + $this->backup->dump_all = $this->dumpAll; + + $this->customValidate(); + $this->backup->save(); + } } else { $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; @@ -146,12 +247,98 @@ 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; + + 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( + 'At least one backup repository (local or S3) must be enabled for pgBackRest. '. + 'Either enable local backups or configure S3 storage and enable it.' + ); + } + + $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) { + $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 { @@ -194,7 +381,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); @@ -219,14 +406,13 @@ 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; } $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(); } @@ -243,14 +429,19 @@ class BackupEdit extends Component } } + public function isPostgresql(): bool + { + return $this->backup->database_type === StandalonePostgresql::class; + } + 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..af6148dfb 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,73 @@ class BackupExecutions extends Component return redirect()->route('download.backup', $exeuctionId); } + public function confirmRestore(int $executionId) + { + $execution = $this->backup->executions()->where('id', $executionId)->first(); + 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) + { + $execution = $this->backup->executions()->where('id', $executionId)->first(); + 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(); @@ -172,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 new file mode 100644 index 000000000..417d008a7 --- /dev/null +++ b/app/Models/DatabaseRestore.php @@ -0,0 +1,80 @@ + '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, bool $persist = true): void + { + $timestamp = now()->format('Y-m-d H:i:s'); + $this->log = ($this->log ?? '')."[{$timestamp}] {$message}\n"; + + if ($persist) { + $this->save(); + } + } + + public function updateStatus(string $status, ?string $message = null): void + { + $this->status = $status; + + if ($message !== null) { + $this->message = $message; + $this->appendLog($message, persist: false); + } + + 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..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; @@ -10,6 +11,69 @@ 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->enabledPgbackrestRepos()->where('type', 'posix')->first() + ?? $this->pgbackrestRepos()->where('type', 'posix')->first(); + } + + public function s3Repo(): ?PgbackrestRepo + { + return $this->enabledPgbackrestRepos()->where('type', 's3')->first() + ?? $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(): HasManyThrough + { + 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 92b2efd31..201d14404 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -50,6 +50,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(); @@ -345,4 +353,36 @@ 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(); + } } 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..60630db84 --- /dev/null +++ b/app/Services/Backup/PgBackrestService.php @@ -0,0 +1,409 @@ +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 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', + ?string $logLevel = null, + ?int $repoNumber = null + ): string { + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; + + if ($logLevel) { + $escapedLogLevel = escapeshellarg($logLevel); + $cmd .= " --log-level-console={$escapedLogLevel}"; + } + + if ($repoNumber !== null) { + $cmd .= ' --repo='.((int) $repoNumber); + } + + $escapedType = escapeshellarg($type); + $cmd .= " --type={$escapedType} backup"; + + 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 << 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 ce40466b2..4da5a1d80 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3841,6 +3841,55 @@ 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()) { + static $volumePath = null; + if ($volumePath === null) { + $volumePath = discoverDevCoolifyVolumePath(); + } + + return str_replace('/data/coolify', $volumePath, $path); + } + + return $path; +} + +function discoverDevCoolifyVolumePath(): string +{ + $fallback = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data'; + + try { + $server = \App\Models\Server::find(0); + if ($server) { + $output = instant_remote_process( + ["cat /proc/self/mountinfo | grep '/data/coolify ' | head -1"], + $server, + false, + false, + 10, + disableMultiplexing: true + ); + if (preg_match('#(/var/lib/docker/volumes/[^/]+_dev_coolify_data/_data)\s+/data/coolify#', $output, $matches)) { + return $matches[1]; + } + if (preg_match('#/docker/volumes/([^/]+_dev_coolify_data)/_data\s+/data/coolify#', $output, $matches)) { + return '/var/lib/docker/volumes/'.$matches[1].'/_data'; + } + } + } catch (Throwable $e) { + } + + return $fallback; +} + /** * Extract hard-coded environment variables from docker-compose YAML. * diff --git a/database/migrations/2025_12_09_231049_add_pgbackrest_support.php b/database/migrations/2025_12_09_231049_add_pgbackrest_support.php new file mode 100644 index 000000000..78be380ff --- /dev/null +++ b/database/migrations/2025_12_09_231049_add_pgbackrest_support.php @@ -0,0 +1,110 @@ +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']); + }); + + 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->index('status'); + $table->longText('message')->nullable(); + $table->longText('log')->nullable(); + + $table->timestamps(); + $table->timestamp('finished_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('database_restores', function (Blueprint $table) { + $table->dropIndex(['status']); + }); + Schema::dropIfExists('database_restores'); + 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/openapi.json b/openapi.json index 69f5ef53d..3caf954ba 100644 --- a/openapi.json +++ b/openapi.json @@ -3976,6 +3976,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" @@ -4537,6 +4591,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" @@ -5953,6 +6060,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 fab3df54e..8ca9ebbd6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2499,6 +2499,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': @@ -2887,6 +2911,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': @@ -3857,6 +3904,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..858b2fc14 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 ($this->showLocalRepoSettings) +
+

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 b6d88a2fd..3f55dcc9b 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,36 +186,245 @@ @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')) + @can('manage', $database) + + @endcan + @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
No executions found.
@endforelse
+ {{-- Restore Confirmation Modal --}} + @can('manage', $database) + @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 + @endcan + + {{-- 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 + @endcan @endisset
diff --git a/routes/api.php b/routes/api.php index 8b28177f3..d659bdaa8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -156,6 +156,10 @@ Route::group([ Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); + 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']);