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

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

Settings

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

pgBackRest Settings

+ + Enabled + +
+

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

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

Compression

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

Point-in-Time Recovery

+
+ + + + + +
+

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

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

Backup Repositories

+

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

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

S3 Repository

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

Local Repository

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

Settings

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

Backup Retention Settings

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

Local Backup Retention

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

Backup Retention Settings

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

S3 Storage Retention

+

Local Backup Retention

- - + - + + helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." />
- @endif -
+ + @if ($saveS3) +
+

S3 Storage Retention

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

Restore Database from pgBackRest Backup?

+
+ +
+
+

Backup Label:

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

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

+

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

+

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

+

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

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

pgBackRest Restore

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

Backup Label:

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

{{ $currentRestore->message }}

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

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

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

Restore Log:

+
+
{{ $currentRestore->log }}
+
+
+ @endif + + {{-- Action buttons --}} +
+ @if ($currentRestore->isFinished()) + + {{ $currentRestore->isSuccess() ? 'Close' : 'Dismiss' }} + + @endif +
+
+
+ @endif @endisset
diff --git a/routes/api.php b/routes/api.php index aaf7d794b..fa62d5f5b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -151,6 +151,10 @@ Route::group([ Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']); + Route::post('/databases/{uuid}/restore', [DatabasesController::class, 'restore_database'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/restores', [DatabasesController::class, 'list_restores'])->middleware(['api.ability:read']); + Route::get('/databases/{uuid}/restores/{restore_uuid}', [DatabasesController::class, 'restore_status'])->middleware(['api.ability:read']); + Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']); Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index c3addcc82..9a7af8787 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -3953,6 +3953,22 @@ "logo": "svgs/default.webp", "minversion": "0.0.0" }, + "soju": { + "documentation": "https://soju.im/?utm_source=coolify.io", + "slogan": "A user-friendly IRC bouncer with a modern web interface", + "compose": "c2VydmljZXM6CiAgc29qdToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL3NvanU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc29qdS1kYjovZGInCiAgICAgIC0gJ3NvanUtdXBsb2FkczovdXBsb2FkcycKICAgICAgLSAnc29qdS1ydW46L3J1bi9zb2p1JwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9zb2p1L2NvbmZpZwogICAgICAgIHRhcmdldDogL3NvanUtY29uZmlnCiAgICAgICAgY29udGVudDogImRiIHNxbGl0ZTMgL2RiL21haW4uZGJcbm1lc3NhZ2Utc3RvcmUgZGJcbmZpbGUtdXBsb2FkIGZzIC91cGxvYWRzL1xubGlzdGVuIGlyYytpbnNlY3VyZTovLzAuMC4wLjA6NjY2N1xubGlzdGVuIHdzK2luc2VjdXJlOi8vMC4wLjAuMDo4MFxubGlzdGVuIHVuaXgrYWRtaW46Ly8vcnVuL3NvanUvYWRtaW5cbiIKICAgIG5ldHdvcmtzOgogICAgICBkZWZhdWx0OgogICAgICAgIGFsaWFzZXM6CiAgICAgICAgICAtIGdhbWphLWJhY2tlbmQKICBnYW1qYToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL2dhbWphOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9HQU1KQV84MAogICAgZGVwZW5kc19vbjoKICAgICAgLSBzb2p1CnZvbHVtZXM6CiAgc29qdS1kYjogbnVsbAogIHNvanUtdXBsb2FkczogbnVsbAogIHNvanUtcnVuOiBudWxsCg==", + "tags": [ + "irc", + "bouncer", + "chat", + "messaging", + "relay" + ], + "category": "communication", + "logo": "svgs/soju.svg", + "minversion": "0.0.0", + "port": "80" + }, "soketi": { "documentation": "https://docs.soketi.app?utm_source=coolify.io", "slogan": "Soketi is your simple, fast, and resilient open-source WebSockets server.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 5da51e9f0..7791c1750 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -3953,6 +3953,22 @@ "logo": "svgs/default.webp", "minversion": "0.0.0" }, + "soju": { + "documentation": "https://soju.im/?utm_source=coolify.io", + "slogan": "A user-friendly IRC bouncer with a modern web interface", + "compose": "c2VydmljZXM6CiAgc29qdToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL3NvanU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc29qdS1kYjovZGInCiAgICAgIC0gJ3NvanUtdXBsb2FkczovdXBsb2FkcycKICAgICAgLSAnc29qdS1ydW46L3J1bi9zb2p1JwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9zb2p1L2NvbmZpZwogICAgICAgIHRhcmdldDogL3NvanUtY29uZmlnCiAgICAgICAgY29udGVudDogImRiIHNxbGl0ZTMgL2RiL21haW4uZGJcbm1lc3NhZ2Utc3RvcmUgZGJcbmZpbGUtdXBsb2FkIGZzIC91cGxvYWRzL1xubGlzdGVuIGlyYytpbnNlY3VyZTovLzAuMC4wLjA6NjY2N1xubGlzdGVuIHdzK2luc2VjdXJlOi8vMC4wLjAuMDo4MFxubGlzdGVuIHVuaXgrYWRtaW46Ly8vcnVuL3NvanUvYWRtaW5cbiIKICAgIG5ldHdvcmtzOgogICAgICBkZWZhdWx0OgogICAgICAgIGFsaWFzZXM6CiAgICAgICAgICAtIGdhbWphLWJhY2tlbmQKICBnYW1qYToKICAgIGltYWdlOiAnY29kZWJlcmcub3JnL2VtZXJzaW9uL2dhbWphOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9HQU1KQV84MAogICAgZGVwZW5kc19vbjoKICAgICAgLSBzb2p1CnZvbHVtZXM6CiAgc29qdS1kYjogbnVsbAogIHNvanUtdXBsb2FkczogbnVsbAogIHNvanUtcnVuOiBudWxsCg==", + "tags": [ + "irc", + "bouncer", + "chat", + "messaging", + "relay" + ], + "category": "communication", + "logo": "svgs/soju.svg", + "minversion": "0.0.0", + "port": "80" + }, "soketi": { "documentation": "https://docs.soketi.app?utm_source=coolify.io", "slogan": "Soketi is your simple, fast, and resilient open-source WebSockets server.",