CodeRabbit fixes

CodeRabbit fixes

CodeRabbit fixes

Handle case where PGDATA was already empty

S3 fix

Squash migrations
This commit is contained in:
Mahad Kalam 2025-12-10 02:00:12 +00:00 committed by Andras Bacsai
parent b126eaf794
commit bc36e929d0
13 changed files with 243 additions and 200 deletions

View file

@ -18,18 +18,8 @@ class PgBackrestRestore
?ScheduledDatabaseBackupExecution $execution = null,
?string $targetTime = null
): DatabaseRestore {
$attempts = 0;
do {
$uuid = (string) new Cuid2;
$exists = DatabaseRestore::where('uuid', $uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
throw new \Exception('Unable to generate unique UUID for restore after 3 attempts');
}
} while ($exists);
$restore = DatabaseRestore::create([
'uuid' => $uuid,
'uuid' => (string) new Cuid2,
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'engine' => 'pgbackrest',

View file

@ -338,7 +338,11 @@ class StartPostgresql
$content = $this->database->postgres_conf ?? '';
if (! str($content)->contains('listen_addresses')) {
$content .= "\nlisten_addresses = '*'";
if ($this->database->is_public) {
$content .= "\nlisten_addresses = '*'";
} else {
$content .= "\nlisten_addresses = 'localhost'";
}
}
if ($this->hasPgBackrest) {
@ -350,11 +354,9 @@ class StartPostgresql
$content .= "\narchive_mode = on";
}
if (str($content)->contains('archive_command')) {
$content = preg_replace("/archive_command\s*=\s*'[^']*'/", "archive_command = '{$archiveCommand}'", $content);
} else {
$content .= "\narchive_command = '{$archiveCommand}'";
}
$content = preg_replace('/^\s*archive_command\s*=.*$/m', '', $content);
$content = preg_replace('/\n{3,}/', "\n\n", $content);
$content .= "\narchive_command = '{$archiveCommand}'";
if (! str($content)->contains('wal_level')) {
$content .= "\nwal_level = replica";

View file

@ -47,6 +47,51 @@ class DatabasesController extends Controller
return serializeApiResponse($database);
}
/**
* Allowed fields for backup configuration API requests.
*/
private function getBackupConfigFields(): array
{
return [
'save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup',
'database_backup_retention_amount_locally', 'database_backup_retention_days_locally',
'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3',
'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3',
's3_storage_uuid',
'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level',
'pgbackrest_log_level', 'pgbackrest_archive_mode',
];
}
/**
* Build backup configuration data from request input.
* Converts s3_storage_uuid to s3_storage_id and filters to allowed fields.
*
* @return array{data: array, error: \Illuminate\Http\JsonResponse|null}
*/
private function buildBackupConfig(Request $request, bool $requireSaveS3 = false): array
{
$backupData = $request->only($this->getBackupConfigFields());
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($requireSaveS3 || $request->boolean('save_s3')) {
return [
'data' => [],
'error' => response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422),
];
}
unset($backupData['s3_storage_uuid']);
}
return ['data' => $backupData, 'error' => null];
}
#[OA\Get(
summary: 'List',
description: 'List all databases.',
@ -685,22 +730,13 @@ class DatabasesController extends Controller
)]
public function create_backup(Request $request)
{
$backupConfigFields = [
'save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup',
'database_backup_retention_amount_locally', 'database_backup_retention_days_locally',
'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3',
'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3',
's3_storage_uuid',
'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level',
'pgbackrest_log_level', 'pgbackrest_archive_mode',
];
$backupConfigFields = $this->getBackupConfigFields();
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate incoming request is valid JSON
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
@ -796,21 +832,11 @@ class DatabasesController extends Controller
], 422);
}
$backupData = $request->only($backupConfigFields);
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
unset($backupData['s3_storage_uuid']);
$result = $this->buildBackupConfig($request);
if ($result['error']) {
return $result['error'];
}
$backupData = $result['data'];
// Set default databases_to_backup based on database type if not provided
if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) {
@ -933,25 +959,18 @@ class DatabasesController extends Controller
)]
public function update_backup(Request $request)
{
$backupConfigFields = [
'save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup',
'database_backup_retention_amount_locally', 'database_backup_retention_days_locally',
'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3',
'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3',
's3_storage_uuid',
'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level',
'pgbackrest_log_level', 'pgbackrest_archive_mode',
];
$backupConfigFields = $this->getBackupConfigFields();
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// this check if the request is a valid json
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'save_s3' => 'boolean',
'backup_now' => 'boolean|nullable',
@ -973,6 +992,7 @@ class DatabasesController extends Controller
'pgbackrest_log_level' => 'string|in:off,error,warn,info,detail,debug,trace|nullable',
'pgbackrest_archive_mode' => 'string|in:standard,reduced,minimal|nullable',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
@ -984,7 +1004,6 @@ class DatabasesController extends Controller
return response()->json(['message' => 'UUID is required.'], 404);
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
@ -1012,6 +1031,7 @@ class DatabasesController extends Controller
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
], 422);
}
if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
@ -1042,21 +1062,11 @@ class DatabasesController extends Controller
], 422);
}
$backupData = $request->only($backupConfigFields);
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
unset($backupData['s3_storage_uuid']);
$result = $this->buildBackupConfig($request);
if ($result['error']) {
return $result['error'];
}
$backupData = $result['data'];
$backupConfig->update($backupData);
@ -2883,6 +2893,18 @@ class DatabasesController extends Controller
return response()->json(['message' => 'UUID is required.'], 400);
}
$validator = customApiValidator($request->all(), [
'execution_uuid' => 'string|uuid|nullable',
'target_time' => 'date|nullable',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
@ -2899,7 +2921,7 @@ class DatabasesController extends Controller
}
$execution = null;
if ($request->has('execution_uuid')) {
if ($request->filled('execution_uuid')) {
$execution = ScheduledDatabaseBackupExecution::where('uuid', $request->execution_uuid)
->whereHas('scheduledDatabaseBackup', function ($query) use ($database) {
$query->where('database_id', $database->id)
@ -2914,7 +2936,10 @@ class DatabasesController extends Controller
}
}
$targetTime = $request->input('target_time');
$targetTime = null;
if ($request->filled('target_time')) {
$targetTime = \Carbon\Carbon::parse($request->input('target_time'))->toIso8601String();
}
$restore = PgBackrestRestore::run($database, $execution, $targetTime);

View file

@ -67,9 +67,10 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue
$this->restore->appendLog('Starting PostgreSQL after restore.');
StartPostgresql::run($this->database);
// Restore succeeded, safely remove the backup
$this->restore->appendLog('Removing backup of previous database.');
$this->removePgDataBackup($backupPath);
if ($backupPath) {
$this->restore->appendLog('Removing backup of previous database.');
$this->removePgDataBackup($backupPath);
}
$this->restore->updateStatus('success', 'PgBackRest restore completed successfully.');
@ -82,9 +83,10 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue
));
}
} catch (Throwable $restoreError) {
// Restore failed, attempt to recover
$this->restore->appendLog('Restore failed, attempting to recover from backup: '.$restoreError->getMessage());
$this->recoverFromBackup($backupPath);
if ($backupPath) {
$this->recoverFromBackup($backupPath);
}
throw $restoreError;
}
} catch (Throwable $e) {
@ -237,7 +239,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue
return $info;
}
private function backupCurrentPgData(int $timestamp): string
private function backupCurrentPgData(int $timestamp): ?string
{
$server = $this->database->destination->server;
$pgdataVolume = $this->database->pgdataVolume();
@ -247,10 +249,27 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue
}
$mount = $pgdataVolume->host_path ?: $pgdataVolume->name;
$checkCmd = "docker run --rm -v {$mount}:/data alpine sh -c 'test -n \"$(ls -A /data 2>/dev/null)\" && echo OK || echo EMPTY'";
$checkResult = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true);
if (trim($checkResult) === 'EMPTY') {
$this->restore->appendLog('No existing PGDATA to backup (directory is empty).');
return null;
}
$backupPath = "{$mount}_backup_{$timestamp}";
$backupCmd = "docker run --rm -v {$mount}:/data -v {$mount}_backup_{$timestamp}:/backup alpine sh -c 'cp -a /data/* /backup/ 2>/dev/null || true'";
instant_remote_process([$backupCmd], $server, false, false, 600, disableMultiplexing: true);
$backupCmd = "docker run --rm -v {$mount}:/data -v {$mount}_backup_{$timestamp}:/backup alpine sh -c 'cp -a /data/. /backup/'";
instant_remote_process([$backupCmd], $server, true, false, 600, disableMultiplexing: true);
$verifyCmd = "docker run --rm -v {$backupPath}:/backup alpine sh -c 'test -n \"$(ls -A /backup 2>/dev/null)\" && echo OK'";
$result = instant_remote_process([$verifyCmd], $server, false, false, 30, disableMultiplexing: true);
if (trim($result) !== 'OK') {
throw new \RuntimeException('PGDATA backup verification failed: backup directory is empty or inaccessible.');
}
$this->restore->appendLog("PGDATA backed up to temporary location: {$backupPath}");

View file

@ -122,6 +122,19 @@ class BackupEdit extends Component
#[Validate(['nullable', 'integer', 'min:1'])]
public ?int $s3RepoRetentionDiff = 7;
public function getShowLocalRepoSettingsProperty(): bool
{
return ! $this->disableLocalBackup || ! $this->saveS3;
}
/**
* Toggle between pgBackRest and native backup engines.
*/
public function togglePgbackrestEngine(): void
{
$this->engine = $this->engine === 'pgbackrest' ? 'native' : 'pgbackrest';
}
public function mount()
{
try {
@ -140,6 +153,8 @@ class BackupEdit extends Component
$this->backup->frequency = $this->frequency;
$this->backup->engine = $this->engine;
$this->backup->timeout = $this->timeout;
if ($this->engine === 'pgbackrest') {
$this->backup->save_s3 = $this->saveS3;
$this->backup->disable_local_backup = $this->saveS3 && $this->disableLocalBackup;
@ -150,6 +165,7 @@ class BackupEdit extends Component
$this->backup->pgbackrest_log_level = $this->pgbackrestLogLevel;
$this->backup->pgbackrest_archive_mode = $this->pgbackrestArchiveMode;
$this->customValidate();
$this->backup->save();
$this->syncPgbackrestRepos();
} else {
@ -169,9 +185,9 @@ class BackupEdit extends Component
$dbName = trim($db);
try {
validateShellSafePath($dbName, 'database name');
} catch (\Exception $e) {
} catch (Exception $e) {
$position = $index + 1;
throw new \Exception(
throw new Exception(
"Database #{$position} ('{$dbName}') validation failed: ".
$e->getMessage()
);
@ -181,12 +197,10 @@ class BackupEdit extends Component
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->customValidate();
$this->backup->save();
}
$this->backup->timeout = $this->timeout;
$this->customValidate();
$this->backup->save();
} else {
$this->backupEnabled = $this->backup->enabled;
$this->frequency = $this->backup->frequency;
@ -245,19 +259,24 @@ class BackupEdit extends Component
private function syncPgbackrestRepos(): void
{
$hasLocal = ! $this->disableLocalBackup;
if ($this->saveS3 && empty($this->s3RepoStorageId)) {
if ($this->s3s->isNotEmpty()) {
$this->s3RepoStorageId = $this->s3s->first()->id;
} else {
throw new Exception('S3 storage must be selected when S3 backups are enabled.');
}
}
$hasS3 = $this->saveS3 && ! empty($this->s3RepoStorageId);
if (! $hasLocal && ! $hasS3) {
throw new \Exception(
throw new Exception(
'At least one backup repository (local or S3) must be enabled for pgBackRest. '.
'Either enable local backups or configure S3 storage and enable it.'
);
}
if ($hasS3 && empty($this->s3RepoStorageId)) {
throw new \Exception('S3 storage must be selected when S3 backups are enabled.');
}
$repoNumber = 1;
if ($hasLocal) {
@ -279,10 +298,6 @@ class BackupEdit extends Component
}
if ($hasS3) {
if (empty($this->s3RepoStorageId)) {
throw new \Exception('S3 Storage is required when enabling S3 backups for pgBackRest.');
}
$s3Repo = $this->backup->s3Repo();
if (! $s3Repo) {
$s3Repo = new PgbackrestRepo;
@ -352,7 +367,7 @@ class BackupEdit extends Component
} else {
return redirect()->route('project.database.backup.index', $this->parameters);
}
} catch (\Exception $e) {
} catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return handleError($e, $this);
@ -383,7 +398,7 @@ class BackupEdit extends Component
$isValid = validate_cron_expression($this->backup->frequency);
if (! $isValid) {
throw new \Exception('Invalid Cron / Human expression');
throw new Exception('Invalid Cron / Human expression');
}
$this->validate();
}

View file

@ -127,7 +127,7 @@ class BackupExecutions extends Component
public function confirmRestore(int $executionId)
{
$execution = ScheduledDatabaseBackupExecution::find($executionId);
$execution = $this->backup->executions()->where('id', $executionId)->first();
if (! $execution || ! $execution->canRestore()) {
$this->dispatch('error', 'This backup cannot be restored.');
@ -144,17 +144,9 @@ class BackupExecutions extends Component
$this->showRestoreModal = false;
}
public function startRestore(int $executionId, $password = null, $selectedActions = [])
public function startRestore(int $executionId)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! $password || ! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
}
$execution = ScheduledDatabaseBackupExecution::find($executionId);
$execution = $this->backup->executions()->where('id', $executionId)->first();
if (! $execution || ! $execution->canRestore()) {
$this->dispatch('error', 'This backup cannot be restored.');
@ -268,6 +260,7 @@ class BackupExecutions extends Component
{
$this->backup = $backup;
$this->database = $backup->database;
$this->authorize('view', $this->database);
$this->updateCurrentPage();
$this->loadExecutions();
}

View file

@ -52,11 +52,14 @@ class DatabaseRestore extends BaseModel
return in_array($this->status, ['success', 'failed']);
}
public function appendLog(string $message): void
public function appendLog(string $message, bool $persist = true): void
{
$timestamp = now()->format('Y-m-d H:i:s');
$this->log = ($this->log ?? '')."[{$timestamp}] {$message}\n";
$this->save();
if ($persist) {
$this->save();
}
}
public function updateStatus(string $status, ?string $message = null): void
@ -65,7 +68,7 @@ class DatabaseRestore extends BaseModel
if ($message !== null) {
$this->message = $message;
$this->appendLog($message);
$this->appendLog($message, persist: false);
}
if ($this->isFinished()) {

View file

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@ -43,12 +44,14 @@ class ScheduledDatabaseBackup extends BaseModel
public function localRepo(): ?PgbackrestRepo
{
return $this->pgbackrestRepos()->where('type', 'posix')->first();
return $this->enabledPgbackrestRepos()->where('type', 'posix')->first()
?? $this->pgbackrestRepos()->where('type', 'posix')->first();
}
public function s3Repo(): ?PgbackrestRepo
{
return $this->pgbackrestRepos()->where('type', 's3')->first();
return $this->enabledPgbackrestRepos()->where('type', 's3')->first()
?? $this->pgbackrestRepos()->where('type', 's3')->first();
}
public function hasLocalRepo(): bool
@ -61,7 +64,7 @@ class ScheduledDatabaseBackup extends BaseModel
return $this->pgbackrestRepos()->where('type', 's3')->where('enabled', true)->exists();
}
public function restores(): HasMany
public function restores(): HasManyThrough
{
return $this->hasManyThrough(
DatabaseRestore::class,

View file

@ -198,17 +198,20 @@ BASH;
?string $logLevel = null,
?int $repoNumber = null
): string {
$cmd = "pgbackrest --stanza={$stanza}";
$escapedStanza = escapeshellarg($stanza);
$cmd = "pgbackrest --stanza={$escapedStanza}";
if ($logLevel) {
$cmd .= " --log-level-console={$logLevel}";
$escapedLogLevel = escapeshellarg($logLevel);
$cmd .= " --log-level-console={$escapedLogLevel}";
}
if ($repoNumber !== null) {
$cmd .= " --repo={$repoNumber}";
$cmd .= ' --repo='.((int) $repoNumber);
}
$cmd .= " --type={$type} backup";
$escapedType = escapeshellarg($type);
$cmd .= " --type={$escapedType} backup";
return $cmd;
}
@ -220,22 +223,26 @@ BASH;
?string $logLevel = null,
?int $repoNumber = null
): string {
$cmd = "pgbackrest --stanza={$stanza}";
$escapedStanza = escapeshellarg($stanza);
$cmd = "pgbackrest --stanza={$escapedStanza}";
if ($logLevel) {
$cmd .= " --log-level-console={$logLevel}";
$escapedLogLevel = escapeshellarg($logLevel);
$cmd .= " --log-level-console={$escapedLogLevel}";
}
if ($repoNumber !== null) {
$cmd .= " --repo={$repoNumber}";
$cmd .= ' --repo='.((int) $repoNumber);
}
if ($label) {
$cmd .= " --set={$label}";
$escapedLabel = escapeshellarg($label);
$cmd .= " --set={$escapedLabel}";
}
if ($targetTime) {
$cmd .= " --type=time --target=\"{$targetTime}\" --target-action=promote";
$escapedTargetTime = escapeshellarg($targetTime);
$cmd .= " --type=time --target={$escapedTargetTime} --target-action=promote";
}
$cmd .= ' restore';
@ -245,10 +252,11 @@ BASH;
public static function buildInfoCommand(string $stanza, bool $json = true, ?int $repoNumber = null): string
{
$cmd = "pgbackrest --stanza={$stanza}";
$escapedStanza = escapeshellarg($stanza);
$cmd = "pgbackrest --stanza={$escapedStanza}";
if ($repoNumber !== null) {
$cmd .= " --repo={$repoNumber}";
$cmd .= ' --repo='.((int) $repoNumber);
}
if ($json) {
@ -262,17 +270,20 @@ BASH;
public static function buildStanzaCreateCommand(string $stanza): string
{
return "pgbackrest --stanza={$stanza} --log-level-console=info stanza-create";
$escapedStanza = escapeshellarg($stanza);
return "pgbackrest --stanza={$escapedStanza} --log-level-console=info stanza-create";
}
public static function buildExpireCommand(
string $stanza,
?int $repoNumber = null
): string {
$cmd = "pgbackrest --stanza={$stanza}";
$escapedStanza = escapeshellarg($stanza);
$cmd = "pgbackrest --stanza={$escapedStanza}";
if ($repoNumber !== null) {
$cmd .= " --repo={$repoNumber}";
$cmd .= ' --repo='.((int) $repoNumber);
}
$cmd .= ' expire';

View file

@ -52,10 +52,34 @@ return new class extends Migration
$table->unique(['scheduled_database_backup_id', 'repo_number']);
});
Schema::create('database_restores', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->morphs('database');
$table->foreignId('scheduled_database_backup_execution_id')
->nullable()
->constrained('scheduled_database_backup_executions')
->nullOnDelete();
$table->string('engine')->default('pgbackrest');
$table->string('target_label')->nullable();
$table->timestamp('target_time')->nullable();
$table->string('status')->default('pending');
$table->longText('message')->nullable();
$table->longText('log')->nullable();
$table->timestamps();
$table->timestamp('finished_at')->nullable();
});
}
public function down(): void
{
Schema::dropIfExists('database_restores');
Schema::dropIfExists('pgbackrest_repos');
Schema::table('scheduled_database_backup_executions', function (Blueprint $table) {

View file

@ -1,43 +0,0 @@
<?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

@ -24,7 +24,7 @@
<x-forms.checkbox instantSave label="Use pgBackRest"
id="usePgbackrest"
:checked="$engine === 'pgbackrest'"
wire:click="$set('engine', '{{ $engine === 'pgbackrest' ? 'native' : 'pgbackrest' }}')"
wire:click="togglePgbackrestEngine"
helper="Use pgBackRest instead of pg_dump for efficient incremental backups with point-in-time recovery support." />
@endif
@if ($engine !== 'pgbackrest')
@ -166,7 +166,7 @@
@endif
{{-- Local Repository Settings --}}
@if (!$disableLocalBackup || !$saveS3)
@if ($this->showLocalRepoSettings)
<div class="mb-6">
<h4 class="mb-3">Local Repository</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">

View file

@ -192,20 +192,22 @@
@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" />
@can('manage', $database)
<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" />
@endcan
@endif
@endif
@php
@ -296,24 +298,23 @@
</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 x-data="{ confirmValue: '' }">
<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-model="confirmValue" />
</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 class="flex justify-end gap-3">
<x-forms.button wire:click="cancelRestore">Cancel</x-forms.button>
<x-forms.button isError
x-on:click="if (confirmValue === 'restore') { $wire.startRestore({{ $restoreExecutionId }}) }"
x-bind:disabled="confirmValue !== 'restore'">
Restore Database
</x-forms.button>
</div>
</div>
</div>
</div>