feat: pgbackrest

pgBackRest

Further code quality changes

Clean up code

Small fix

Hopefully the last bit of cleanup?

Remove old comment!

A couple more improvements

Squash migrations
This commit is contained in:
Mahad Kalam 2025-12-10 00:55:34 +00:00 committed by Andras Bacsai
parent 7c50f6011a
commit b126eaf794
30 changed files with 3509 additions and 166 deletions

View file

@ -0,0 +1,53 @@
<?php
namespace App\Actions\Database;
use App\Jobs\PgBackrestRestoreJob;
use App\Models\DatabaseRestore;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\StandalonePostgresql;
use Lorisleiva\Actions\Concerns\AsAction;
use Visus\Cuid2\Cuid2;
class PgBackrestRestore
{
use AsAction;
public function handle(
StandalonePostgresql $database,
?ScheduledDatabaseBackupExecution $execution = null,
?string $targetTime = null
): DatabaseRestore {
$attempts = 0;
do {
$uuid = (string) new Cuid2;
$exists = DatabaseRestore::where('uuid', $uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
throw new \Exception('Unable to generate unique UUID for restore after 3 attempts');
}
} while ($exists);
$restore = DatabaseRestore::create([
'uuid' => $uuid,
'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'],
];
}
}

View file

@ -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 = <<<BASH
#!/bin/bash
set -e
mkdir -p /tmp/pgbackrest
mkdir -p /var/lib/pgbackrest/log
NEED_INSTALL=0
if ! command -v pgbackrest &> /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);
}
}

View file

@ -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);
}
}

View file

@ -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', [

View file

@ -0,0 +1,439 @@
<?php
namespace App\Jobs;
use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StopDatabase;
use App\Models\DatabaseRestore;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\StandalonePostgresql;
use App\Notifications\Database\DatabaseRestoreFailed;
use App\Notifications\Database\DatabaseRestoreSuccess;
use App\Services\Backup\PgBackrestService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 7200;
public $maxExceptions = 1;
public function __construct(
public StandalonePostgresql $database,
public DatabaseRestore $restore,
public ?ScheduledDatabaseBackupExecution $execution = null,
public ?string $targetTime = null
) {
$this->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
));
}
}
}

View file

@ -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(),
]);
}
}

View file

@ -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();

View file

@ -0,0 +1,77 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class DatabaseRestore extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
'target_time' => '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();
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Visus\Cuid2\Cuid2;
class PgbackrestRepo extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
'enabled' => '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();
}
}

View file

@ -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');

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,129 @@
<?php
namespace App\Notifications\Database;
use App\Models\StandalonePostgresql;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class DatabaseRestoreFailed extends CustomEmailNotification
{
public string $name;
public string $error;
public ?string $label;
public function __construct(
public StandalonePostgresql $database,
string $error,
?string $label = null
) {
$this->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 .= "<br/><b>Backup:</b> {$this->label}";
}
$message .= "<br/><br/><b>Error:</b> {$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,
];
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace App\Notifications\Database;
use App\Models\StandalonePostgresql;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class DatabaseRestoreSuccess extends CustomEmailNotification
{
public string $name;
public ?string $label;
public ?string $targetTime;
public function __construct(
public StandalonePostgresql $database,
?string $label = null,
?string $targetTime = null
) {
$this->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 .= "<br/><b>Backup:</b> {$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,
];
}
}

View file

@ -0,0 +1,353 @@
<?php
namespace App\Services\Backup;
use App\Models\PgbackrestRepo;
use App\Models\ScheduledDatabaseBackup;
use App\Models\StandalonePostgresql;
class PgBackrestService
{
public const PGDATA_PATH = '/var/lib/postgresql/data';
public const REPO_PATH = '/var/lib/pgbackrest';
public const CONFIG_PATH = '/etc/pgbackrest';
public const DEFAULT_COMPRESS_TYPE = 'lz4';
public const DEFAULT_COMPRESS_LEVEL = 6;
public const DEFAULT_LOG_LEVEL = 'info';
public static function getStanzaName(StandalonePostgresql $database): string
{
return $database->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;
}
}

View file

@ -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

View file

@ -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;
}

View file

@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('scheduled_database_backups', function (Blueprint $table) {
$table->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',
]);
});
}
};

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('database_restores', function (Blueprint $table) {
$table->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');
}
};

View file

@ -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": [

View file

@ -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:

View file

@ -0,0 +1,8 @@
<x-emails.layout>
Database restore for {{ $name }} has failed.
@if($label)
Attempted to restore from backup: {{ $label }}
@endif
Error: {{ $error }}
</x-emails.layout>

View file

@ -0,0 +1,9 @@
<x-emails.layout>
Database restore for {{ $name }} was successful.
@if($label)
Restored from backup: {{ $label }}
@endif
@if($target_time)
Target time: {{ $target_time }}
@endif
</x-emails.layout>

View file

@ -20,66 +20,208 @@
</div>
<div class="w-64 pb-2">
<x-forms.checkbox instantSave label="Backup Enabled" id="backupEnabled" />
@if ($s3s->count() > 0)
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3" />
@else
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
disabled />
@if ($isPostgresql && $backup->database_id !== 0)
<x-forms.checkbox instantSave label="Use pgBackRest"
id="usePgbackrest"
:checked="$engine === 'pgbackrest'"
wire:click="$set('engine', '{{ $engine === 'pgbackrest' ? 'native' : 'pgbackrest' }}')"
helper="Use pgBackRest instead of pg_dump for efficient incremental backups with point-in-time recovery support." />
@endif
@if ($backup->save_s3)
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@else
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@if ($engine !== 'pgbackrest')
@if ($s3s->count() > 0)
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3" />
@else
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
disabled />
@endif
@if ($saveS3)
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3." />
@else
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
helper="Requires S3 backup to be enabled." />
@endif
@endif
</div>
@if ($backup->save_s3)
@if ($engine !== 'pgbackrest' && $saveS3)
<div class="pb-6">
<x-forms.select id="s3StorageId" label="S3 Storage" required>
<option value="default" disabled>Select a S3 storage</option>
<option value="" disabled>Select a S3 storage</option>
@foreach ($s3s as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach
</x-forms.select>
</div>
@endif
<div class="flex flex-col gap-2">
<h3>Settings</h3>
<div class="flex gap-2 flex-col ">
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0)
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@if ($engine === 'pgbackrest')
{{-- PgBackRest Settings Panel --}}
<div class="flex flex-col gap-4 p-4 mb-6 border border-coolgray-300 dark:border-coolgray-400 rounded-lg bg-white dark:bg-coolgray-100">
<div class="flex items-center gap-1">
<h3 class="text-lg font-medium">pgBackRest Settings</h3>
<span class="px-2 py-1 text-xs font-medium rounded-md bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
Enabled
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
pgBackRest provides efficient incremental backups with compression, parallel operations, and point-in-time recovery support.
</p>
{{-- Backup Type --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<x-forms.select id="pgbackrestBackupType" label="Backup Type" required>
<option value="full">Full - Complete database backup</option>
<option value="diff">Differential - Changes since last full backup</option>
<option value="incr">Incremental - Changes since last backup</option>
</x-forms.select>
</div>
{{-- Compression Settings --}}
<h4 class="mt-4 font-medium">Compression</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<x-forms.select id="pgbackrestCompressType" label="Compression Type">
<option value="lz4">LZ4 (Recommended - Fast)</option>
<option value="zst">Zstandard (Better compression)</option>
<option value="gz">Gzip (Compatible)</option>
<option value="bz2">Bzip2 (High compression)</option>
<option value="none">None</option>
</x-forms.select>
<x-forms.input type="number" id="pgbackrestCompressLevel" label="Compression Level" min="0" max="9"
helper="0 = no compression, 9 = maximum compression. Default: 6" />
</div>
{{-- Log Level --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<x-forms.select id="pgbackrestLogLevel" label="Log Level">
<option value="info">Info (Default)</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="detail">Detail</option>
<option value="debug">Debug</option>
<option value="trace">Trace</option>
<option value="off">Off</option>
</x-forms.select>
</div>
{{-- Point-in-Time Recovery Mode --}}
<h4 class="mt-4 font-medium">Point-in-Time Recovery</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<x-forms.select id="pgbackrestArchiveMode" label="Archive Mode">
<option value="standard">Standard - Restore to any point in time</option>
<option value="reduced">Reduced - Limited PITR range, less storage</option>
<option value="minimal">Minimal - Backup points only, minimal storage</option>
</x-forms.select>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
'Standard' keeps full WAL history for restoring to any moment. 'Minimal' only allows restoring to exact backup points but uses less storage.
</p>
{{-- Repository Configuration --}}
<h3 class="mt-6">Backup Repositories</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Configure where backups are stored. You can enable both local and S3 storage for redundancy.
</p>
<div class="w-64 mb-6">
@if ($s3s->count() > 0)
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3"
helper="Store backups in S3 using pgBackRest's native S3 support." />
@else
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
disabled />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneMongodb')
<x-forms.input label="Databases To Include"
helper="A list of databases to backup. You can specify which collection(s) per database to exclude from the backup. Empty will include all databases and collections.<br><br>Example:<br><br>database1:collection1,collection2|database2:collection3,collection4<br><br> database1 will include all collections except collection1 and collection2. <br>database2 will include all collections except collection3 and collection4.<br><br>Another Example:<br><br>database1:collection1|database2<br><br> database1 will include all collections except collection1.<br>database2 will include ALL collections."
id="databasesToBackup" />
@elseif($backup->database_type === 'App\Models\StandaloneMysql')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@if ($saveS3)
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backups will only be stored in S3." />
@else
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
helper="Requires S3 to be enabled." />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneMariadb')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
{{-- S3 Repository Settings --}}
@if ($saveS3)
<div class="mb-6">
<h4 class="mb-3">S3 Repository</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<x-forms.select id="s3RepoStorageId" label="S3 Storage" required>
<option value="" disabled>Select S3 storage</option>
@foreach ($s3s as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<x-forms.select id="s3RepoRetentionFullType" label="Full Retention Type">
<option value="count">Count (number of backups)</option>
<option value="time">Time (days)</option>
</x-forms.select>
<x-forms.input type="number" id="s3RepoRetentionFull"
label="{{ $s3RepoRetentionFullType === 'time' ? 'Days to Keep' : 'Backups to Keep' }}"
min="1" />
<x-forms.input type="number" id="s3RepoRetentionDiff" label="Diff Backups to Keep" min="1" />
</div>
</div>
@endif
{{-- Local Repository Settings --}}
@if (!$disableLocalBackup || !$saveS3)
<div class="mb-6">
<h4 class="mb-3">Local Repository</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<x-forms.select id="localRepoRetentionFullType" label="Full Retention Type">
<option value="count">Count (number of backups)</option>
<option value="time">Time (days)</option>
</x-forms.select>
<x-forms.input type="number" id="localRepoRetentionFull"
label="{{ $localRepoRetentionFullType === 'time' ? 'Days to Keep' : 'Backups to Keep' }}"
min="1" />
<x-forms.input type="number" id="localRepoRetentionDiff" label="Diff Backups to Keep" min="1" />
</div>
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@endif
</div>
@endif
<div class="flex flex-col gap-2">
<h3>Settings</h3>
@if ($engine !== 'pgbackrest')
<div class="flex gap-2 flex-col ">
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0)
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneMongodb')
<x-forms.input label="Databases To Include"
helper="A list of databases to backup. You can specify which collection(s) per database to exclude from the backup. Empty will include all databases and collections.<br><br>Example:<br><br>database1:collection1,collection2|database2:collection3,collection4<br><br> database1 will include all collections except collection1 and collection2. <br>database2 will include all collections except collection3 and collection4.<br><br>Another Example:<br><br>database1:collection1|database2<br><br> database1 will include all collections except collection1.<br>database2 will include ALL collections."
id="databasesToBackup" />
@elseif($backup->database_type === 'App\Models\StandaloneMysql')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneMariadb')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@endif
</div>
@endif
<div class="flex gap-2">
<x-forms.input label="Frequency" id="frequency" />
<x-forms.input label="Timezone" id="timezone" disabled
@ -87,46 +229,48 @@
<x-forms.input label="Timeout" id="timeout" helper="The timeout of the backup job in seconds." />
</div>
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
<div class="mb-4">
<ul class="list-disc pl-6 space-y-2">
<li>Setting a value to 0 means unlimited retention.</li>
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
</ul>
</div>
<div class="flex gap-6 flex-col">
<div>
<h4 class="mb-3 font-medium">Local Backup Retention</h4>
<div class="flex gap-2">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountLocally"
type="number" min="0"
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysLocally" type="number"
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
type="number" min="0"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." />
</div>
@if ($engine !== 'pgbackrest')
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
<div class="mb-4">
<ul class="list-disc pl-6 space-y-2">
<li>Setting a value to 0 means unlimited retention.</li>
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
</ul>
</div>
@if ($backup->save_s3)
<div class="flex gap-6 flex-col">
<div>
<h4 class="mb-3 font-medium">S3 Storage Retention</h4>
<h4 class="mb-3 font-medium">Local Backup Retention</h4>
<div class="flex gap-2">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountS3"
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountLocally"
type="number" min="0"
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysS3" type="number"
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysLocally" type="number"
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
type="number" min="0"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." />
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." />
</div>
</div>
@endif
</div>
@if ($saveS3)
<div>
<h4 class="mb-3 font-medium">S3 Storage Retention</h4>
<div class="flex gap-2">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountS3"
type="number" min="0"
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysS3" type="number"
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
type="number" min="0"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." />
</div>
</div>
@endif
</div>
@endif
</div>
</form>

View file

@ -66,6 +66,15 @@
@endphp
{{ $statusText }}
</span>
{{-- Show pgBackRest badge if this is a pgBackRest backup --}}
@if (data_get($execution, 'engine') === 'pgbackrest')
<span class="px-2 py-1 rounded-md text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-200">
pgBackRest
@if (data_get($execution, 'pgbackrest_backup_type'))
({{ ucfirst(data_get($execution, 'pgbackrest_backup_type')) }})
@endif
</span>
@endif
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
@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
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
Location: {{ data_get($execution, 'filename', 'N/A') }}
@if (data_get($execution, 'engine') === 'pgbackrest' && data_get($execution, 'pgbackrest_label'))
Label: <code class="px-1 py-0.5 bg-gray-200 dark:bg-coolgray-300 rounded text-xs">{{ data_get($execution, 'pgbackrest_label') }}</code>
@else
Location: {{ data_get($execution, 'filename', 'N/A') }}
@endif
</div>
<div class="flex items-center gap-3 mt-2">
<div class="text-gray-600 dark:text-gray-400 text-sm">
Backup Availability:
</div>
<span @class([
'px-2 py-1 rounded-sm text-xs font-medium',
'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),
])>
<span class="flex items-center gap-1">
@if (!data_get($execution, 'local_storage_deleted', false))
@if (data_get($execution, 'engine') === 'pgbackrest')
{{-- For pgBackRest, show repository status instead of local/S3 --}}
<span class="px-2 py-1 rounded-sm text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"></path>
</svg>
@else
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"></path>
</svg>
@endif
Local Storage
pgBackRest Repository
</span>
</span>
</span>
@if (data_get($execution, 's3_uploaded') !== null)
@else
<span @class([
'px-2 py-1 rounded-sm text-xs font-medium',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => 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),
])>
<span class="flex items-center gap-1">
@if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false))
@if (!data_get($execution, 'local_storage_deleted', false))
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
@ -144,9 +147,36 @@
clip-rule="evenodd"></path>
</svg>
@endif
S3 Storage
Local Storage
</span>
</span>
@if (data_get($execution, 's3_uploaded') !== null)
<span @class([
'px-2 py-1 rounded-sm text-xs font-medium',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => 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),
])>
<span class="flex items-center gap-1">
@if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false))
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"></path>
</svg>
@else
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"></path>
</svg>
@endif
S3 Storage
</span>
</span>
@endif
@endif
</div>
@if (data_get($execution, 'message'))
@ -156,30 +186,54 @@
@endif
<div class="flex gap-2 mt-4">
@if (data_get($execution, 'status') === 'success')
<x-forms.button class="dark:hover:bg-coolgray-400"
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
@if (data_get($execution, 'engine') !== 'pgbackrest')
<x-forms.button class="dark:hover:bg-coolgray-400"
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
@endif
{{-- Show Restore button for successful pgBackRest backups --}}
@if (data_get($execution, 'engine') === 'pgbackrest' && data_get($execution, 'pgbackrest_label'))
<x-modal-confirmation
title="Restore Database from pgBackRest Backup?"
buttonTitle="Restore"
isErrorButton
submitAction="startRestore({{ data_get($execution, 'id') }})"
:actions="[
'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.',
]"
confirmationText="restore"
confirmationLabel="Please type 'restore' to confirm this action"
shortConfirmationLabel="Confirmation" />
@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
<x-modal-confirmation title="Confirm Backup Deletion?" buttonTitle="Delete" isErrorButton
submitAction="deleteBackup({{ data_get($execution, 'id') }})" :checkboxes="$executionCheckboxes"
:actions="$deleteActions" confirmationText="{{ data_get($execution, 'filename') }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename" 1 />
: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' }}" />
</div>
</div>
@empty
@ -191,5 +245,187 @@
window.open('/download/backup/' + executionId, '_blank');
}
</script>
{{-- Restore Confirmation Modal --}}
@if ($showRestoreModal && $restoreExecutionId)
@php
$restoreExecution = $executions->firstWhere('id', $restoreExecutionId);
@endphp
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" wire:key="restore-modal">
<div class="bg-white dark:bg-coolgray-100 rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h3 class="text-lg font-semibold">Restore Database from pgBackRest Backup?</h3>
</div>
<div class="space-y-3 mb-6">
<div class="p-3 bg-gray-100 dark:bg-coolgray-200 rounded">
<p class="text-sm font-medium mb-1">Backup Label:</p>
<code class="text-sm">{{ data_get($restoreExecution, 'pgbackrest_label') }}</code>
</div>
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p class="flex items-start gap-2">
<svg class="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
This will stop the PostgreSQL database and restore it from the selected backup.
</p>
<p class="flex items-start gap-2">
<svg class="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
All data written after this backup was taken will be permanently lost.
</p>
<p class="flex items-start gap-2">
<svg class="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
The database will be temporarily unavailable during the restore process.
</p>
<p class="flex items-start gap-2">
<svg class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
After restore, the database will automatically restart.
</p>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Type <code class="px-1 py-0.5 bg-gray-200 dark:bg-coolgray-300 rounded">restore</code> to confirm:</label>
<input type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-coolgray-400 rounded-md bg-white dark:bg-coolgray-200 text-black dark:text-white"
placeholder="restore"
x-data="{ value: '' }"
x-model="value"
x-ref="restoreConfirmInput" />
</div>
<div class="flex justify-end gap-3">
<x-forms.button wire:click="cancelRestore">Cancel</x-forms.button>
<x-forms.button isError
x-data
x-on:click="if ($refs.restoreConfirmInput && $refs.restoreConfirmInput.value === 'restore') { $wire.startRestore($refs.restoreConfirmInput.value) }"
x-bind:disabled="!$refs.restoreConfirmInput || $refs.restoreConfirmInput.value !== 'restore'">
Restore Database
</x-forms.button>
</div>
</div>
</div>
@endif
{{-- Restore Progress Modal --}}
@if ($showRestoreProgressModal && $currentRestore)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
wire:key="restore-progress-modal"
@if (!$currentRestore->isFinished()) wire:poll.2000ms="pollRestoreStatus" @endif>
<div class="bg-white dark:bg-coolgray-100 rounded-lg shadow-xl max-w-lg w-full mx-4 p-6">
{{-- Header with status icon --}}
<div class="flex items-center gap-3 mb-4">
@if ($currentRestore->isRunning() || $currentRestore->isPending())
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
@elseif ($currentRestore->isSuccess())
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
</div>
@elseif ($currentRestore->isFailed())
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
</div>
@endif
<div>
<h3 class="text-lg font-semibold">pgBackRest Restore</h3>
<span @class([
'text-sm',
'text-blue-600 dark:text-blue-400' => $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) }}
</span>
</div>
@if ($currentRestore->isFinished())
<button wire:click="closeRestoreProgress" class="ml-auto text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
@endif
</div>
{{-- Progress bar for running state --}}
@if ($currentRestore->isRunning() || $currentRestore->isPending())
<div class="h-1 bg-gray-200 dark:bg-coolgray-300 rounded-full mb-4 overflow-hidden">
<div class="h-full bg-blue-500 rounded-full animate-pulse" style="width: 100%"></div>
</div>
@endif
{{-- Backup label --}}
@if ($currentRestore->target_label)
<div class="p-3 bg-gray-100 dark:bg-coolgray-200 rounded mb-4">
<p class="text-sm font-medium mb-1">Backup Label:</p>
<code class="text-sm">{{ $currentRestore->target_label }}</code>
</div>
@endif
{{-- Status message --}}
@if ($currentRestore->message)
<div @class([
'p-3 rounded mb-4',
'bg-gray-100 dark:bg-coolgray-200' => $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(),
])>
<p class="text-sm">{{ $currentRestore->message }}</p>
</div>
@endif
{{-- Info banner for running state --}}
@if ($currentRestore->isRunning() || $currentRestore->isPending())
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<svg class="w-4 h-4 inline-block mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
The database is temporarily unavailable during restore. This modal will update automatically when the restore completes.
</p>
</div>
@endif
{{-- Log output (scrollable) --}}
@if ($currentRestore->log && ($currentRestore->isFailed() || $currentRestore->isSuccess()))
<div class="mb-4">
<p class="text-sm font-medium mb-2">Restore Log:</p>
<div class="max-h-48 overflow-y-auto p-3 bg-gray-900 dark:bg-black rounded">
<pre class="text-xs text-gray-300 whitespace-pre-wrap">{{ $currentRestore->log }}</pre>
</div>
</div>
@endif
{{-- Action buttons --}}
<div class="flex justify-end gap-3">
@if ($currentRestore->isFinished())
<x-forms.button wire:click="closeRestoreProgress">
{{ $currentRestore->isSuccess() ? 'Close' : 'Dismiss' }}
</x-forms.button>
@endif
</div>
</div>
</div>
@endif
@endisset
</div>

View file

@ -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']);

View file

@ -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.",

View file

@ -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.",