mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
CodeRabbit fixes
CodeRabbit fixes CodeRabbit fixes Handle case where PGDATA was already empty S3 fix Squash migrations
This commit is contained in:
parent
b126eaf794
commit
bc36e929d0
13 changed files with 243 additions and 200 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue