diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php new file mode 100644 index 000000000..d6dc63f45 --- /dev/null +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -0,0 +1,314 @@ + []]], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')), + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), + new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + #[OA\Put( + summary: 'Set database storage backup schedule', + description: 'Create or replace the backup schedule for a database persistent volume or directory storage.', + path: '/databases/{uuid}/storages/{storage_uuid}/backups', + operationId: 'set-database-storage-backup-schedule', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')), + tags: ['Databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), + new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + #[OA\Put( + summary: 'Set service storage backup schedule', + description: 'Create or replace the backup schedule for a service persistent volume or directory storage.', + path: '/services/{uuid}/storages/{storage_uuid}/backups', + operationId: 'set-service-storage-backup-schedule', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')), + tags: ['Services'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the service.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), + new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function upsert(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $invalidRequest = validateIncomingRequest($request); + if ($invalidRequest instanceof JsonResponse) { + return $invalidRequest; + } + + $resourceType = $request->route('resource_type'); + $resource = $this->findResource($resourceType, $request->route('uuid'), $teamId); + if (! $resource) { + return response()->json([ + 'message' => match ($resourceType) { + 'application' => 'Application not found.', + 'database' => 'Database not found.', + 'service' => 'Service not found.', + default => 'Resource not found.', + }, + ], 404); + } + + $this->authorize('update', $resource); + + $storage = $this->findStorage($resource, $request->route('storage_uuid')); + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + $validator = customApiValidator($request->all(), [ + 'frequency' => 'required|string|max:255', + 'enabled' => 'boolean', + 'save_s3' => 'boolean', + 'disable_local_backup' => 'boolean', + 'stop_during_backup' => 'boolean', + 's3_storage_uuid' => 'nullable|string', + 'retention_amount_locally' => 'integer|min:0|max:10000', + 'retention_days_locally' => 'integer|min:0|max:2147483647', + 'retention_max_storage_locally' => 'numeric|min:0|max:9999999999', + 'retention_amount_s3' => 'integer|min:0|max:10000', + 'retention_days_s3' => 'integer|min:0|max:2147483647', + 'retention_max_storage_s3' => 'numeric|min:0|max:9999999999', + 'timeout' => 'integer|min:60|max:36000', + ]); + $errors = $validator->errors(); + $allowedFields = [ + 'frequency', + 'enabled', + 'save_s3', + 'disable_local_backup', + 'stop_during_backup', + 's3_storage_uuid', + 'retention_amount_locally', + 'retention_days_locally', + 'retention_max_storage_locally', + 'retention_amount_s3', + 'retention_days_s3', + 'retention_max_storage_s3', + 'timeout', + ]; + + foreach (array_diff(array_keys($request->all()), $allowedFields) as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + if (! $errors->has('frequency') && ! validate_cron_expression($request->string('frequency')->toString())) { + $errors->add('frequency', 'The frequency must be a valid cron or human expression.'); + } + + $saveToS3 = $request->boolean('save_s3'); + if ($request->boolean('disable_local_backup') && ! $saveToS3) { + $errors->add('disable_local_backup', 'Local backups can only be disabled when S3 backups are enabled.'); + } + + $s3Storage = null; + if ($saveToS3) { + $s3Storage = S3Storage::query() + ->where('team_id', $teamId) + ->where('is_usable', true) + ->where('uuid', $request->input('s3_storage_uuid')) + ->first(); + + if (! $s3Storage) { + $errors->add('s3_storage_uuid', 'Select a usable S3 storage owned by your team.'); + } + } + + if ($storage instanceof LocalFileVolume && (! $storage->is_directory || $storage->is_host_file)) { + $errors->add('storage_uuid', 'Only directory file storages can be backed up.'); + } + + if ($errors->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $backup = $storage->scheduledBackups()->updateOrCreate([], [ + 'team_id' => $teamId, + 'frequency' => $request->string('frequency')->toString(), + 'enabled' => $request->boolean('enabled', true), + 'save_s3' => $saveToS3, + 'disable_local_backup' => $saveToS3 && $request->boolean('disable_local_backup'), + 'stop_during_backup' => $request->boolean('stop_during_backup'), + 's3_storage_id' => $s3Storage?->id, + 'retention_amount_locally' => $request->integer('retention_amount_locally', 7), + 'retention_days_locally' => $request->integer('retention_days_locally'), + 'retention_max_storage_locally' => $request->float('retention_max_storage_locally'), + 'retention_amount_s3' => $request->integer('retention_amount_s3', 7), + 'retention_days_s3' => $request->integer('retention_days_s3'), + 'retention_max_storage_s3' => $request->float('retention_max_storage_s3'), + 'timeout' => $request->integer('timeout', 3600), + ]); + $created = $backup->wasRecentlyCreated; + + auditLog('api.volume_backup.schedule_set', [ + 'team_id' => $teamId, + 'resource_type' => $resourceType, + 'resource_uuid' => $resource->uuid, + 'storage_uuid' => $storage->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200); + } + + private function findResource(string $resourceType, string $uuid, int|string $teamId): ?Model + { + return match ($resourceType) { + 'application' => Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(), + 'database' => queryDatabaseByUuidWithinTeam($uuid, $teamId), + 'service' => Service::query()->whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $uuid)->first(), + default => null, + }; + } + + private function findStorage(Model $resource, string $storageUuid): LocalPersistentVolume|LocalFileVolume|null + { + if ($resource instanceof Service) { + foreach ($resource->applications->concat($resource->databases) as $serviceResource) { + $storage = $this->findStorage($serviceResource, $storageUuid); + if ($storage) { + return $storage; + } + } + + return null; + } + + $storage = $resource->persistentStorages()->where('uuid', $storageUuid)->first(); + + return $storage ?? $resource->fileStorages()->where('uuid', $storageUuid)->first(); + } + + private function responseData( + ScheduledVolumeBackup $backup, + LocalPersistentVolume|LocalFileVolume $storage, + ?S3Storage $s3Storage, + bool $created, + ): array { + return [ + 'uuid' => $backup->uuid, + 'message' => $created ? 'Storage backup schedule created.' : 'Storage backup schedule updated.', + 'storage_uuid' => $storage->uuid, + 'storage_type' => $storage instanceof LocalFileVolume ? 'directory' : 'persistent', + 'frequency' => $backup->frequency, + 'enabled' => $backup->enabled, + 'save_s3' => $backup->save_s3, + 'disable_local_backup' => $backup->disable_local_backup, + 'stop_during_backup' => $backup->stop_during_backup, + 's3_storage_uuid' => $s3Storage?->uuid, + 'retention_amount_locally' => $backup->retention_amount_locally, + 'retention_days_locally' => $backup->retention_days_locally, + 'retention_max_storage_locally' => $backup->retention_max_storage_locally, + 'retention_amount_s3' => $backup->retention_amount_s3, + 'retention_days_s3' => $backup->retention_days_s3, + 'retention_max_storage_s3' => $backup->retention_max_storage_s3, + 'timeout' => $backup->timeout, + ]; + } +} diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 7d7f285c9..01b122a8d 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -356,7 +356,7 @@ private function processScheduledTask(ScheduledTask $task, ?Server $precheckedSe private function processScheduledVolumeBackups(): void { ScheduledVolumeBackup::query() - ->with(['volume', 'team.subscription']) + ->with(['backupable', 'team.subscription']) ->where('enabled', true) ->chunkById(self::CHUNK_SIZE, function ($backups): void { foreach ($backups as $backup) { @@ -397,7 +397,7 @@ private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): vo return; } - if (! $backup->volume || ! $server) { + if (! $backup->backupable || ! $server) { $backup->delete(); $this->skippedCount++; $this->logSkip('volume_backup', 'resource_deleted', [ @@ -435,7 +435,8 @@ private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): vo $this->dispatchedCount++; Log::channel('scheduled')->info('Volume backup dispatched', [ 'backup_id' => $backup->id, - 'volume_id' => $backup->local_persistent_volume_id, + 'backupable_type' => $backup->backupable_type, + 'backupable_id' => $backup->backupable_id, 'team_id' => $backup->team_id, 'server_id' => $server->id, ]); diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 99d2cd618..98b5373bf 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Events\BackupCreated; +use App\Models\LocalPersistentVolume; use App\Models\ScheduledVolumeBackup; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; @@ -53,30 +54,30 @@ public static function lockKey(int $backupId): string public function handle(): void { - $this->backup->loadMissing(['volume.resource', 'team', 's3']); + $this->backup->loadMissing(['backupable.resource', 'team', 's3']); $server = $this->backup->server(); - $volume = $this->backup->volume; + $target = $this->backup->backupable; $team = $this->backup->team; - if (! $server || ! $volume || ! $team) { - throw new \RuntimeException('The volume backup resource, team, or server no longer exists.'); + if (! $server || ! $target || ! $team) { + throw new \RuntimeException('The storage backup resource, team, or server no longer exists.'); } $this->execution = $this->backup->executions()->create(); BackupCreated::dispatch($team->id); - $backupDirectory = backup_dir().'/volumes/'.str($team->name)->slug().'-'.$team->id.'/'.$volume->uuid; - $filename = 'volume-'.str($volume->name)->slug().'-'.Carbon::now()->timestamp.'.tar.gz'; + $backupDirectory = backup_dir().'/volumes/'.str($team->name)->slug().'-'.$team->id.'/'.$target->uuid; + $filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz'; $backupLocation = $backupDirectory.'/'.$filename; $this->execution->update(['filename' => $backupLocation]); try { - $source = filled($volume->host_path) ? $volume->host_path : $volume->name; + $source = $this->backup->sourcePath(); $containerName = 'volume-backup-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); - $verifySourceCommand = filled($volume->host_path) - ? 'test -d '.escapeshellarg($source) - : 'docker volume inspect '.escapeshellarg($source).' >/dev/null'; + $verifySourceCommand = $target instanceof LocalPersistentVolume && blank($target->host_path) + ? 'docker volume inspect '.escapeshellarg($source).' >/dev/null' + : 'test -d '.escapeshellarg($source); $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) .' -v '.escapeshellarg($source.':/volume:ro') @@ -112,7 +113,7 @@ public function handle(): void ); if ($size <= 0) { - throw new \RuntimeException('The volume backup archive is empty or was not created.'); + throw new \RuntimeException('The storage backup archive is empty or was not created.'); } $warning = null; diff --git a/app/Jobs/VolumeBackupRecoveryJob.php b/app/Jobs/VolumeBackupRecoveryJob.php index 44e7d47cb..48db9b0a8 100644 --- a/app/Jobs/VolumeBackupRecoveryJob.php +++ b/app/Jobs/VolumeBackupRecoveryJob.php @@ -46,7 +46,7 @@ public function handle(): void public static function recover(ScheduledVolumeBackupExecution $execution): void { - $execution->loadMissing('scheduledVolumeBackup.volume.resource'); + $execution->loadMissing('scheduledVolumeBackup.backupable.resource'); if ($execution->stop_recovery_pending) { self::recoverContainers($execution); diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php index c69421778..6c22bd7f3 100644 --- a/app/Livewire/Project/Application/Backup/Create.php +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -3,8 +3,8 @@ namespace App\Livewire\Project\Application\Backup; use App\Models\Application; -use App\Models\S3Storage; -use App\Models\ScheduledVolumeBackup; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -17,44 +17,52 @@ class Create extends Component #[Locked] public Application $application; - public ?int $selectedVolumeId = null; + public ?string $selectedTargetKey = null; - public ?int $volumeId = null; + public ?string $targetKey = null; - public bool $volumeLocked = false; + public bool $targetLocked = false; public string $frequency = 'daily'; - public bool $saveToS3 = false; - - public ?int $s3StorageId = null; - - public Collection $volumes; - - public Collection $definedS3s; + public Collection $targets; protected function rules(): array { return [ - 'volumeId' => ['required', 'integer'], + 'targetKey' => ['required', 'string', 'regex:/^(volume|directory):[1-9][0-9]*$/'], 'frequency' => ['required', 'string'], - 'saveToS3' => ['required', 'boolean'], - 's3StorageId' => ['nullable', 'integer'], ]; } public function mount(): void { $this->authorize('view', $this->application); - $this->volumes = $this->application->persistentStorages()->orderBy('name')->get(); - $this->definedS3s = S3Storage::ownedByCurrentTeam()->where('is_usable', true)->get(); - $this->s3StorageId = $this->definedS3s->first()?->id; - $this->volumeLocked = $this->selectedVolumeId !== null; - $this->volumeId = $this->selectedVolumeId ?? $this->volumes->first()?->id; + $volumes = $this->application->persistentStorages() + ->orderBy('name') + ->get() + ->map(fn (LocalPersistentVolume $volume): array => [ + 'key' => 'volume:'.$volume->id, + 'type' => 'Volume', + 'name' => $volume->name, + ]); + $directories = $this->application->fileStorages() + ->where('is_directory', true) + ->where('is_host_file', false) + ->orderBy('fs_path') + ->get() + ->map(fn (LocalFileVolume $directory): array => [ + 'key' => 'directory:'.$directory->id, + 'type' => 'Directory', + 'name' => $directory->fs_path, + ]); + $this->targets = $volumes->concat($directories)->values(); + $this->targetLocked = $this->selectedTargetKey !== null; + $this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key'); $this->loadSelectedBackup(); } - public function updatedVolumeId(): void + public function updatedTargetKey(): void { $this->loadSelectedBackup(); } @@ -64,9 +72,9 @@ public function submit(): void $this->authorize('update', $this->application); $this->validate(); - $volume = $this->application->persistentStorages()->whereKey($this->volumeId)->first(); - if (! $volume) { - $this->addError('volumeId', 'Select a volume owned by this application.'); + $target = $this->selectedTarget(); + if (! $target) { + $this->addError('targetKey', 'Select a volume or directory owned by this application.'); return; } @@ -77,26 +85,22 @@ public function submit(): void return; } - if ($this->saveToS3 && ! $this->validS3StorageExists()) { - $this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.'); - - return; - } - - $backup = ScheduledVolumeBackup::query()->updateOrCreate( - ['local_persistent_volume_id' => $volume->id], + $backup = $target->scheduledBackups()->updateOrCreate( + [], [ 'team_id' => currentTeam()->id, 'frequency' => $this->frequency, 'enabled' => true, - 'save_s3' => $this->saveToS3, - 's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null, ], ); - $this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled volume backup created.' : 'Scheduled volume backup updated.'); - $this->dispatch('refreshVolumeBackups'); - $this->dispatch('close-modal'); + $this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.'); + $this->redirectRoute('project.application.backup.show', [ + 'project_uuid' => $this->application->project()->uuid, + 'environment_uuid' => $this->application->environment->uuid, + 'application_uuid' => $this->application->uuid, + 'backup_uuid' => $backup->uuid, + ], navigate: true); } public function render() @@ -106,25 +110,32 @@ public function render() private function loadSelectedBackup(): void { - $volume = $this->volumes->firstWhere('id', $this->volumeId); - if (! $volume) { + $target = $this->selectedTarget(); + if (! $target) { return; } - $backup = $volume->scheduledBackups()->first(); + $backup = $target->scheduledBackups()->first(); if ($backup) { $this->frequency = $backup->frequency; - $this->saveToS3 = $backup->save_s3; - $this->s3StorageId = $backup->s3_storage_id ?? $this->definedS3s->first()?->id; } } - private function validS3StorageExists(): bool + private function selectedTarget(): LocalPersistentVolume|LocalFileVolume|null { - return $this->s3StorageId !== null - && S3Storage::ownedByCurrentTeam() - ->where('is_usable', true) - ->whereKey($this->s3StorageId) - ->exists(); + [$type, $id] = array_pad(explode(':', (string) $this->targetKey, 2), 2, null); + if (! ctype_digit((string) $id)) { + return null; + } + + return match ($type) { + 'volume' => $this->application->persistentStorages()->whereKey((int) $id)->first(), + 'directory' => $this->application->fileStorages() + ->whereKey((int) $id) + ->where('is_directory', true) + ->where('is_host_file', false) + ->first(), + default => null, + }; } } diff --git a/app/Livewire/Project/Application/Backup/Index.php b/app/Livewire/Project/Application/Backup/Index.php index e5dd3c304..130396a9b 100644 --- a/app/Livewire/Project/Application/Backup/Index.php +++ b/app/Livewire/Project/Application/Backup/Index.php @@ -16,6 +16,8 @@ class Index extends Component public array $parameters; + public string $search = ''; + protected $listeners = ['refreshVolumeBackups' => '$refresh']; public function mount(): void @@ -23,15 +25,15 @@ public function mount(): void $this->application = $this->findApplication(); $this->authorize('view', $this->application); $this->parameters = get_route_parameters(); + $this->search = request()->string('search')->toString(); } public function render(): View { - $volumeIds = $this->application->persistentStorages()->pluck('id'); $backups = ScheduledVolumeBackup::query() - ->with(['volume', 'latestExecution']) + ->with(['backupable', 'latestExecution']) ->withCount('executions') - ->whereIn('local_persistent_volume_id', $volumeIds) + ->forApplication($this->application) ->latest() ->get(); diff --git a/app/Livewire/Project/Application/Backup/Show.php b/app/Livewire/Project/Application/Backup/Show.php index f43593390..5e10f0c8a 100644 --- a/app/Livewire/Project/Application/Backup/Show.php +++ b/app/Livewire/Project/Application/Backup/Show.php @@ -17,6 +17,8 @@ class Show extends Component public array $parameters; + public string $section = 'general'; + public function mount(): void { $project = currentTeam()->projects() @@ -32,11 +34,18 @@ public function mount(): void $this->authorize('view', $this->application); $this->backup = ScheduledVolumeBackup::query() - ->with('volume') + ->with('backupable') ->where('uuid', request()->route('backup_uuid')) - ->whereIn('local_persistent_volume_id', $this->application->persistentStorages()->pluck('id')) + ->forApplication($this->application) ->firstOrFail(); $this->parameters = get_route_parameters(); + $this->section = match (request()->route()?->getName()) { + 'project.application.backup.s3' => 's3', + 'project.application.backup.retention' => 'retention', + 'project.application.backup.executions' => 'executions', + 'project.application.backup.danger' => 'danger', + default => 'general', + }; } public function render() diff --git a/app/Livewire/Project/Database/Backup/Execution.php b/app/Livewire/Project/Database/Backup/Execution.php index 4ac3b2e2c..00c177008 100644 --- a/app/Livewire/Project/Database/Backup/Execution.php +++ b/app/Livewire/Project/Database/Backup/Execution.php @@ -15,6 +15,10 @@ class Execution extends Component public $s3s; + public array $parameters = []; + + public string $section = 'general'; + public function mount() { $backup_uuid = request()->route('backup_uuid'); @@ -39,6 +43,14 @@ public function mount() $this->backup = $backup; $this->executions = $executions; $this->s3s = currentTeam()->s3s; + $this->parameters = get_route_parameters(); + $this->section = match (request()->route()?->getName()) { + 'project.database.backup.s3' => 's3', + 'project.database.backup.retention' => 'retention', + 'project.database.backup.executions' => 'executions', + 'project.database.backup.danger' => 'danger', + default => 'general', + }; } public function render() diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index d8ce9c97f..bbb33e8ed 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -19,6 +19,8 @@ class BackupEdit extends Component public ScheduledDatabaseBackup $backup; + public string $section = 'general'; + #[Locked] public $availableS3Storages; @@ -184,7 +186,11 @@ public function delete($password, $selectedActions = []) 'stack_service_uuid' => $serviceDatabase->uuid, ]); } else { - return redirect()->route('project.database.backup.index', $this->parameters); + return redirect()->route('project.database.backup.index', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'database_uuid' => $this->parameters['database_uuid'], + ]); } } catch (Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); @@ -235,6 +241,19 @@ public function updatedS3StorageId(): void $this->instantSave(); } + public function toggleS3(): void + { + if (! $this->saveS3 && $this->availableS3StorageIds()->isEmpty()) { + $this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.'); + + return; + } + + $this->saveS3 = ! $this->saveS3; + $this->disableLocalBackup = $this->saveS3 && $this->disableLocalBackup; + $this->instantSave(); + } + private function customValidate() { if (! is_numeric($this->backup->s3_storage_id)) { diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 7384adcff..0f37bf802 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -2,11 +2,9 @@ namespace App\Livewire\Project\Database; -use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -18,31 +16,11 @@ class CreateScheduledBackup extends Component #[Validate(['required', 'string'])] public $frequency; - #[Validate(['required', 'boolean'])] - public bool $saveToS3 = false; - #[Locked] public $database; public bool $enabled = true; - #[Validate(['nullable', 'integer'])] - public ?int $s3StorageId = null; - - public Collection $definedS3s; - - public function mount() - { - try { - $this->definedS3s = currentTeam()->s3s; - if ($this->definedS3s->count() > 0) { - $this->s3StorageId = $this->definedS3s->first()->id; - } - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function submit() { try { @@ -50,20 +28,6 @@ public function submit() $this->validate(); - if ($this->saveToS3) { - $s3StorageExists = ! is_null($this->s3StorageId) - && S3Storage::where('team_id', currentTeam()->id) - ->where('is_usable', true) - ->whereKey($this->s3StorageId) - ->exists(); - - if (! $s3StorageExists) { - $this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.'); - - return; - } - } - $isValid = validate_cron_expression($this->frequency); if (! $isValid) { $this->dispatch('error', 'Invalid Cron / Human expression.'); @@ -74,8 +38,8 @@ public function submit() $payload = [ 'enabled' => true, 'frequency' => $this->frequency, - 'save_s3' => $this->saveToS3, - 's3_storage_id' => $this->s3StorageId, + 'save_s3' => false, + 's3_storage_id' => null, 'database_id' => $this->database->id, 'database_type' => $this->database->getMorphClass(), 'team_id' => currentTeam()->id, @@ -91,11 +55,22 @@ public function submit() $databaseBackup = ScheduledDatabaseBackup::create($payload); if ($this->database->getMorphClass() === ServiceDatabase::class) { - $this->dispatch('refreshScheduledBackups', $databaseBackup->id); + $service = $this->database->service; + $this->redirectRoute('project.service.database.backup.show', [ + 'project_uuid' => $service->project()->uuid, + 'environment_uuid' => $service->environment->uuid, + 'service_uuid' => $service->uuid, + 'stack_service_uuid' => $this->database->uuid, + 'backup_uuid' => $databaseBackup->uuid, + ], navigate: true); } else { - $this->dispatch('refreshScheduledBackups'); + $this->redirectRoute('project.database.backup.execution', [ + 'project_uuid' => $this->database->project()->uuid, + 'environment_uuid' => $this->database->environment->uuid, + 'database_uuid' => $this->database->uuid, + 'backup_uuid' => $databaseBackup->uuid, + ], navigate: true); } - } catch (\Throwable $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index 9d1f212e4..77a380ea0 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project\Database; -use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -17,42 +16,18 @@ class ScheduledBackups extends Component public $type; - public ?ScheduledDatabaseBackup $selectedBackup; - - public $selectedBackupId; - - public $s3s; - public string $custom_type = 'mysql'; protected $listeners = ['refreshScheduledBackups']; - protected $queryString = ['selectedBackupId']; - public function mount(): void { - if ($this->selectedBackupId) { - $this->setSelectedBackup($this->selectedBackupId, true); - } $this->parameters = get_route_parameters(); if ($this->database->getMorphClass() === ServiceDatabase::class) { $this->type = 'service-database'; } else { $this->type = 'database'; } - $this->s3s = currentTeam()->s3s; - } - - public function setSelectedBackup($backupId, $force = false) - { - if ($this->selectedBackupId === $backupId && ! $force) { - return; - } - $this->selectedBackupId = $backupId; - $this->selectedBackup = $this->database->scheduledBackups->find($backupId); - if (is_null($this->selectedBackup)) { - $this->selectedBackupId = null; - } } public function setCustomType() @@ -86,9 +61,6 @@ public function delete($scheduled_backup_id): void public function refreshScheduledBackups(?int $id = null): void { $this->database->refresh(); - if ($id) { - $this->setSelectedBackup($id); - } $this->dispatch('refreshScheduledBackups'); } } diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 883441ecb..90907abc6 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Models\ScheduledDatabaseBackup; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -17,16 +18,28 @@ class DatabaseBackups extends Component public array $parameters; + public array $backupParameters = []; + public array $query; public bool $isImportSupported = false; + public ?ScheduledDatabaseBackup $backup = null; + + public string $section = 'index'; + + public $s3s; + protected $listeners = ['refreshScheduledBackups' => '$refresh']; public function mount() { try { - $this->parameters = get_route_parameters(); + $this->parameters = array_filter( + get_route_parameters(), + fn (string $key): bool => $key !== 'backup_uuid', + ARRAY_FILTER_USE_KEY, + ); $this->query = request()->query(); $project = currentTeam() ->projects() @@ -58,6 +71,21 @@ public function mount() $dbType = $this->serviceDatabase->databaseType(); $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + + if (request()->route('backup_uuid')) { + $this->backup = $this->serviceDatabase->scheduledBackups() + ->where('uuid', request()->route('backup_uuid')) + ->firstOrFail(); + $this->s3s = currentTeam()->s3s; + $this->backupParameters = [...$this->parameters, 'backup_uuid' => $this->backup->uuid]; + $this->section = match (request()->route()?->getName()) { + 'project.service.database.backup.s3' => 's3', + 'project.service.database.backup.retention' => 'retention', + 'project.service.database.backup.executions' => 'executions', + 'project.service.database.backup.danger' => 'danger', + default => 'general', + }; + } } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index e869ca91b..31501e4cf 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -4,6 +4,7 @@ use App\Models\Application; use App\Models\LocalFileVolume; +use App\Models\ScheduledVolumeBackup; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; @@ -15,6 +16,7 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\On; use Livewire\Attributes\Validate; use Livewire\Component; @@ -34,6 +36,10 @@ class FileStorage extends Component public bool $isReadOnly = false; + public bool $hasEnabledBackup = false; + + public ?string $backupUrl = null; + #[Validate(['nullable'])] public ?string $content = null; @@ -65,6 +71,36 @@ public function mount() $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large; $this->syncData(); + $this->refreshBackupStatus(); + } + + #[On('refreshVolumeBackups')] + public function refreshBackupStatus(): void + { + $backup = $this->fileStorage->is_directory + ? $this->fileStorage->scheduledBackups()->first() + : null; + + $this->hasEnabledBackup = $backup?->enabled ?? false; + $this->backupUrl = null; + + if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) { + return; + } + + $parameters = [ + 'project_uuid' => $this->resource->project()->uuid, + 'environment_uuid' => $this->resource->environment->uuid, + 'application_uuid' => $this->resource->uuid, + ]; + $hasOtherBackups = ScheduledVolumeBackup::query() + ->forApplication($this->resource) + ->where('id', '!=', $backup->id) + ->exists(); + + $this->backupUrl = $hasOtherBackups + ? route('project.application.backup.index', [...$parameters, 'search' => $this->fileStorage->fs_path]) + : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); } public function syncData(bool $toModel = false): void @@ -135,6 +171,10 @@ public function convertToFile() try { $this->authorize('update', $this->resource); + if ($this->fileStorage->scheduledBackups()->exists()) { + throw new \RuntimeException('Delete this directory backup schedule and its archives before converting it to a file.'); + } + if ($this->fileStorage->is_host_file) { throw new \Exception('Host file mounts are bind-only and cannot be converted.'); } @@ -162,6 +202,12 @@ public function delete($password, $selectedActions = []) return 'The provided password is incorrect.'; } + if ($this->fileStorage->scheduledBackups()->exists()) { + $this->dispatch('error', 'Delete this directory backup schedule and its archives before deleting the directory.'); + + return false; + } + try { $message = 'File deleted.'; if ($this->fileStorage->is_directory) { @@ -174,6 +220,7 @@ public function delete($password, $selectedActions = []) $this->fileStorage->deleteStorageOnServer(); } $this->fileStorage->delete(); + $this->dispatch('configurationChanged'); $this->dispatch('success', $message); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 9b097f2e1..05be7eb8a 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -131,10 +131,12 @@ public function submitPersistentVolume() 'resource_type' => $this->resource->getMorphClass(), ]); $this->resource->refresh(); + $this->dispatch('configurationChanged'); $this->dispatch('success', 'Volume added successfully'); $this->dispatch('closeStorageModal', 'volume'); $this->clearForm(); $this->refreshStorages(); + $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -163,6 +165,7 @@ public function submitFileStorage() 'resource_type' => get_class($this->resource), ]); + $this->dispatch('configurationChanged'); $this->dispatch('success', 'File mount added successfully'); $this->dispatch('closeStorageModal', 'file'); $this->clearForm(); @@ -195,6 +198,7 @@ public function submitHostFileStorage() 'resource_type' => get_class($this->resource), ]); + $this->dispatch('configurationChanged'); $this->dispatch('success', 'Host file mount added successfully'); $this->dispatch('closeStorageModal', 'host-file'); $this->clearForm(); @@ -231,6 +235,7 @@ public function submitFileStorageDirectory() 'resource_type' => get_class($this->resource), ]); + $this->dispatch('configurationChanged'); $this->dispatch('success', 'Directory mount added successfully'); $this->dispatch('closeStorageModal', 'directory'); $this->clearForm(); diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 66ab4a114..242660eec 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -2,7 +2,9 @@ namespace App\Livewire\Project\Shared\Storages; +use App\Models\Application; use App\Models\LocalPersistentVolume; +use App\Models\ScheduledVolumeBackup; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\On; @@ -35,6 +37,8 @@ class Show extends Component public bool $hasEnabledBackup = false; + public ?string $backupUrl = null; + protected $validationAttributes = [ 'name' => 'name', 'mountPath' => 'mount', @@ -94,9 +98,28 @@ public function mount(): void #[On('refreshVolumeBackups')] public function refreshBackupStatus(): void { - $this->hasEnabledBackup = $this->storage->scheduledBackups() - ->where('enabled', true) + $backup = $this->storage->scheduledBackups()->first(); + + $this->hasEnabledBackup = $backup?->enabled ?? false; + $this->backupUrl = null; + + if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) { + return; + } + + $parameters = [ + 'project_uuid' => $this->resource->project()->uuid, + 'environment_uuid' => $this->resource->environment->uuid, + 'application_uuid' => $this->resource->uuid, + ]; + $hasOtherBackups = ScheduledVolumeBackup::query() + ->forApplication($this->resource) + ->where('id', '!=', $backup->id) ->exists(); + + $this->backupUrl = $hasOtherBackups + ? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name]) + : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); } public function instantSave(): void @@ -135,6 +158,7 @@ public function delete($password, $selectedActions = []) $this->storage->delete(); $this->dispatch('refreshStorages'); + $this->dispatch('configurationChanged'); return true; } diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 6041f139f..f86f4dc87 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Shared\Storages; use App\Jobs\VolumeBackupJob; +use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Models\S3Storage; use App\Models\ScheduledVolumeBackup; @@ -18,12 +19,14 @@ class VolumeBackups extends Component use AuthorizesRequests; use WithPagination; - public LocalPersistentVolume $storage; + public LocalPersistentVolume|LocalFileVolume $storage; public $resource; public ?ScheduledVolumeBackup $backup = null; + public string $section = 'general'; + public string $frequency = 'daily'; public bool $enabled = false; @@ -113,7 +116,7 @@ public function save(): void } $this->backup = $this->persistBackup($this->enabled); - $this->dispatch('success', 'Volume backup schedule saved.'); + $this->dispatch('success', 'Storage backup schedule saved.'); } public function instantSave(): void @@ -123,9 +126,37 @@ public function instantSave(): void public function updatedS3StorageId(): void { - if ($this->saveToS3) { - $this->save(); + $this->authorize('update', $this->resource); + + if (! $this->hasValidS3Storage()) { + $this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.'); + + return; } + + $this->resetErrorBag('s3StorageId'); + $this->backup?->update(['s3_storage_id' => $this->s3StorageId]); + $this->dispatch('success', 'S3 storage updated.'); + } + + public function toggleS3(): void + { + $this->authorize('update', $this->resource); + + if (! $this->saveToS3 && ! $this->hasValidS3Storage()) { + $this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.'); + + return; + } + + $this->saveToS3 = ! $this->saveToS3; + $this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup; + $this->backup?->update([ + 'save_s3' => $this->saveToS3, + 'disable_local_backup' => $this->disableLocalBackup, + 's3_storage_id' => $this->s3StorageId, + ]); + $this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.'); } public function toggleEnabled(): void @@ -144,7 +175,7 @@ public function toggleEnabled(): void $this->backup->update(['enabled' => $this->enabled]); } - $this->dispatch('success', $this->enabled ? 'Volume backups enabled.' : 'Volume backups disabled.'); + $this->dispatch('success', $this->enabled ? 'Storage backups enabled.' : 'Storage backups disabled.'); } public function backupNow(): void @@ -161,7 +192,7 @@ public function backupNow(): void $this->backup = $this->persistBackup($this->enabled); VolumeBackupJob::dispatch($this->backup); - $this->dispatch('success', 'Volume backup queued.'); + $this->dispatch('success', 'Storage backup queued.'); } public function delete(?string $password = null, array $selectedActions = []) @@ -179,7 +210,7 @@ public function delete(?string $password = null, array $selectedActions = []) $lock = Cache::lock(VolumeBackupJob::lockKey($this->backup->id), $this->backup->timeout + 300); if (! $lock->get()) { - $this->dispatch('error', 'Wait for the queued or running volume backup to finish before deleting this schedule.'); + $this->dispatch('error', 'Wait for the queued or running storage backup to finish before deleting this schedule.'); return false; } @@ -191,7 +222,7 @@ public function delete(?string $password = null, array $selectedActions = []) ->orWhere('stop_recovery_pending', true) ->orWhere('s3_cleanup_pending', true)) ->exists()) { - $this->dispatch('error', 'Wait for the running volume backup and container recovery to finish before deleting this schedule.'); + $this->dispatch('error', 'Wait for the running storage backup and container recovery to finish before deleting this schedule.'); return false; } @@ -228,7 +259,12 @@ public function delete(?string $password = null, array $selectedActions = []) $this->backup->delete(); $this->backup = null; - $this->dispatch('success', 'Volume backup schedule and archives deleted.'); + $this->dispatch('success', 'Storage backup schedule and archives deleted.'); + $this->redirectRoute('project.application.backup.index', [ + 'project_uuid' => $this->resource->project()->uuid, + 'environment_uuid' => $this->resource->environment->uuid, + 'application_uuid' => $this->resource->uuid, + ], navigate: true); return true; } catch (Throwable $exception) { @@ -361,8 +397,8 @@ private function validateSettings(): bool private function persistBackup(bool $enabled): ScheduledVolumeBackup { - return ScheduledVolumeBackup::query()->updateOrCreate( - ['local_persistent_volume_id' => $this->storage->id], + return $this->storage->scheduledBackups()->updateOrCreate( + [], [ 'team_id' => currentTeam()->id, 'frequency' => $this->frequency, @@ -370,7 +406,7 @@ private function persistBackup(bool $enabled): ScheduledVolumeBackup 'save_s3' => $this->saveToS3, 'disable_local_backup' => $this->disableLocalBackup, 'stop_during_backup' => $this->stopDuringBackup, - 's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null, + 's3_storage_id' => $this->hasValidS3Storage() ? $this->s3StorageId : $this->backup?->s3_storage_id, 'retention_amount_locally' => $this->retentionAmountLocally, 'retention_days_locally' => $this->retentionDaysLocally, 'retention_max_storage_locally' => $this->retentionMaxStorageLocally, diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 968e6c3d0..a03ec486b 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -6,6 +6,8 @@ use App\Jobs\ServerStorageSaveJob; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Symfony\Component\Yaml\Yaml; class LocalFileVolume extends BaseModel @@ -57,6 +59,12 @@ protected static function booted() $fileVolume->load(['service']); dispatch(new ServerStorageSaveJob($fileVolume)); }); + + static::deleting(function (LocalFileVolume $fileVolume): void { + if ($fileVolume->scheduledBackups()->exists()) { + throw new \RuntimeException('Delete this directory backup schedule and its archives before deleting the directory.'); + } + }); } protected function isBinary(): Attribute @@ -73,11 +81,21 @@ protected function isTooLarge(): Attribute ); } - public function service() + public function resource(): MorphTo + { + return $this->morphTo(); + } + + public function service(): MorphTo { return $this->morphTo('resource'); } + public function scheduledBackups(): MorphMany + { + return $this->morphMany(ScheduledVolumeBackup::class, 'backupable'); + } + public function loadStorageOnServer() { if ($this->is_host_file) { diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 3b2864ce2..72bb3c38d 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -3,11 +3,20 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Symfony\Component\Yaml\Yaml; class LocalPersistentVolume extends BaseModel { + protected static function booted(): void + { + static::deleting(function (LocalPersistentVolume $volume): void { + if ($volume->scheduledBackups()->exists()) { + throw new \RuntimeException('Delete this volume backup schedule and its archives before deleting the volume.'); + } + }); + } + protected $fillable = [ 'name', 'mount_path', @@ -42,9 +51,9 @@ public function database() return $this->morphTo('resource'); } - public function scheduledBackups(): HasMany + public function scheduledBackups(): MorphMany { - return $this->hasMany(ScheduledVolumeBackup::class, 'local_persistent_volume_id'); + return $this->morphMany(ScheduledVolumeBackup::class, 'backupable'); } protected function customizeName($value) diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php index 055230332..6cdc651fb 100644 --- a/app/Models/ScheduledVolumeBackup.php +++ b/app/Models/ScheduledVolumeBackup.php @@ -2,15 +2,19 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphTo; class ScheduledVolumeBackup extends BaseModel { protected $fillable = [ 'uuid', - 'local_persistent_volume_id', + 'backupable_type', + 'backupable_id', 'team_id', 's3_storage_id', 'frequency', @@ -44,9 +48,28 @@ protected function casts(): array ]; } - public function volume(): BelongsTo + public function scopeForApplication(Builder $query, Application $application): Builder { - return $this->belongsTo(LocalPersistentVolume::class, 'local_persistent_volume_id'); + $volumeIds = $application->persistentStorages()->pluck('id'); + $directoryIds = $application->fileStorages() + ->where('is_directory', true) + ->where('is_host_file', false) + ->pluck('id'); + + return $query->where(function (Builder $query) use ($volumeIds, $directoryIds): void { + $query->where(function (Builder $query) use ($volumeIds): void { + $query->where('backupable_type', (new LocalPersistentVolume)->getMorphClass()) + ->whereIn('backupable_id', $volumeIds); + })->orWhere(function (Builder $query) use ($directoryIds): void { + $query->where('backupable_type', (new LocalFileVolume)->getMorphClass()) + ->whereIn('backupable_id', $directoryIds); + }); + }); + } + + public function backupable(): MorphTo + { + return $this->morphTo(); } public function team(): BelongsTo @@ -71,7 +94,7 @@ public function latestExecution(): HasOne public function server(): ?Server { - $resource = $this->volume?->resource; + $resource = $this->targetResource(); if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) { return $resource->service?->server?->fresh(); @@ -79,4 +102,48 @@ public function server(): ?Server return $resource?->destination?->server?->fresh(); } + + public function targetResource(): ?Model + { + return $this->backupable?->resource; + } + + public function targetType(): string + { + return $this->backupable instanceof LocalFileVolume ? 'Directory' : 'Volume'; + } + + public function targetName(): string + { + return match (true) { + $this->backupable instanceof LocalFileVolume => $this->backupable->fs_path, + $this->backupable instanceof LocalPersistentVolume => $this->backupable->name, + default => 'Unknown storage', + }; + } + + public function sourcePath(): string + { + $target = $this->backupable; + + if ($target instanceof LocalPersistentVolume) { + return filled($target->host_path) ? $target->host_path : $target->name; + } + + if (! $target instanceof LocalFileVolume || ! $target->is_directory) { + throw new \RuntimeException('The backup target is not a directory or persistent volume.'); + } + + $path = str($target->fs_path); + if ($path->startsWith('.')) { + $resource = $this->targetResource(); + if (! $resource || ! method_exists($resource, 'workdir')) { + throw new \RuntimeException('The directory backup workdir is unavailable.'); + } + + return $resource->workdir().$path->after('.')->toString(); + } + + return $path->toString(); + } } diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index aeb40364a..d7ba8ebd1 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -4,6 +4,8 @@ use App\Models\Application; use App\Models\EnvironmentVariable; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; use Illuminate\Support\Arr; @@ -47,6 +49,10 @@ public function toArray(): array 'label' => 'Environment Variables', 'items' => $this->environmentItems(), ], + 'storage' => [ + 'label' => 'Storage', + 'items' => $this->storageItems(), + ], ], ]; } @@ -214,6 +220,40 @@ private function environmentItems(): array ->all(); } + /** + * @return array> + */ + private function storageItems(): array + { + $volumes = $this->application->persistentStorages() + ->orderBy('id') + ->get(['id', 'name', 'mount_path', 'host_path']) + ->map(function (LocalPersistentVolume $volume): array { + $source = $volume->host_path ?: $volume->name; + + return $this->item( + key: 'volume_'.$volume->id, + label: 'Volume mount', + value: ['source' => $source, 'destination' => $volume->mount_path], + impact: 'redeploy', + displayValue: "{$source} → {$volume->mount_path}", + ); + }); + + $fileMounts = $this->application->fileStorages() + ->orderBy('id') + ->get(['id', 'fs_path', 'mount_path', 'is_directory']) + ->map(fn (LocalFileVolume $file): array => $this->item( + key: 'file_'.$file->id, + label: $file->is_directory ? 'Directory mount' : 'File mount', + value: ['source' => $file->fs_path, 'destination' => $file->mount_path], + impact: 'redeploy', + displayValue: "{$file->fs_path} → {$file->mount_path}", + )); + + return collect($volumes->all())->merge($fileMounts->all())->values()->all(); + } + /** * @return array> */ diff --git a/database/migrations/2026_07_15_102537_create_scheduled_volume_backups_table.php b/database/migrations/2026_07_15_102537_create_scheduled_volume_backups_table.php index f5d49c990..c84b8c0f9 100644 --- a/database/migrations/2026_07_15_102537_create_scheduled_volume_backups_table.php +++ b/database/migrations/2026_07_15_102537_create_scheduled_volume_backups_table.php @@ -14,7 +14,8 @@ public function up(): void Schema::create('scheduled_volume_backups', function (Blueprint $table) { $table->id(); $table->string('uuid')->unique(); - $table->foreignId('local_persistent_volume_id')->unique()->constrained()->restrictOnDelete(); + $table->string('backupable_type'); + $table->unsignedBigInteger('backupable_id'); $table->foreignId('team_id')->constrained()->cascadeOnDelete(); $table->foreignId('s3_storage_id')->nullable()->constrained()->nullOnDelete(); $table->string('frequency'); @@ -26,6 +27,8 @@ public function up(): void $table->unsignedInteger('retention_amount_s3')->default(7); $table->unsignedInteger('timeout')->default(3600); $table->timestamps(); + + $table->unique(['backupable_type', 'backupable_id']); }); } diff --git a/openapi.json b/openapi.json index 093b61010..99c3d666c 100644 --- a/openapi.json +++ b/openapi.json @@ -15716,6 +15716,252 @@ ] } }, + "\/applications\/{uuid}\/storages\/{storage_uuid}\/backups": { + "put": { + "tags": [ + "Applications" + ], + "summary": "Set application storage backup schedule", + "description": "Create or replace the backup schedule for an application persistent volume or directory storage.", + "operationId": "set-application-storage-backup-schedule", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the persistent volume or directory storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Backup schedule replaced.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse" + } + } + } + }, + "201": { + "description": "Backup schedule created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/storages\/{storage_uuid}\/backups": { + "put": { + "tags": [ + "Databases" + ], + "summary": "Set database storage backup schedule", + "description": "Create or replace the backup schedule for a database persistent volume or directory storage.", + "operationId": "set-database-storage-backup-schedule", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the persistent volume or directory storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Backup schedule replaced.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse" + } + } + } + }, + "201": { + "description": "Backup schedule created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/storages\/{storage_uuid}\/backups": { + "put": { + "tags": [ + "Services" + ], + "summary": "Set service storage backup schedule", + "description": "Create or replace the backup schedule for a service persistent volume or directory storage.", + "operationId": "set-service-storage-backup-schedule", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the persistent volume or directory storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Backup schedule replaced.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse" + } + } + } + }, + "201": { + "description": "Backup schedule created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/vultr\/regions": { "get": { "tags": [ @@ -15852,6 +16098,169 @@ }, "components": { "schemas": { + "VolumeBackupScheduleRequest": { + "required": [ + "frequency" + ], + "properties": { + "frequency": { + "type": "string", + "maxLength": 255, + "example": "0 2 * * *" + }, + "enabled": { + "type": "boolean", + "default": true + }, + "save_s3": { + "type": "boolean", + "default": false + }, + "disable_local_backup": { + "type": "boolean", + "default": false + }, + "stop_during_backup": { + "type": "boolean", + "default": false + }, + "s3_storage_uuid": { + "type": [ + "string", + "null" + ] + }, + "retention_amount_locally": { + "type": "integer", + "default": 7, + "maximum": 10000, + "minimum": 0 + }, + "retention_days_locally": { + "type": "integer", + "default": 0, + "maximum": 2147483647, + "minimum": 0 + }, + "retention_max_storage_locally": { + "type": "number", + "format": "float", + "default": 0, + "maximum": 9999999999, + "minimum": 0 + }, + "retention_amount_s3": { + "type": "integer", + "default": 7, + "maximum": 10000, + "minimum": 0 + }, + "retention_days_s3": { + "type": "integer", + "default": 0, + "maximum": 2147483647, + "minimum": 0 + }, + "retention_max_storage_s3": { + "type": "number", + "format": "float", + "default": 0, + "maximum": 9999999999, + "minimum": 0 + }, + "timeout": { + "type": "integer", + "default": 3600, + "maximum": 36000, + "minimum": 60 + } + }, + "type": "object", + "additionalProperties": false + }, + "VolumeBackupScheduleResponse": { + "required": [ + "uuid", + "message", + "storage_uuid", + "storage_type", + "frequency", + "enabled", + "save_s3", + "disable_local_backup", + "stop_during_backup", + "retention_amount_locally", + "retention_days_locally", + "retention_max_storage_locally", + "retention_amount_s3", + "retention_days_s3", + "retention_max_storage_s3", + "timeout" + ], + "properties": { + "uuid": { + "type": "string" + }, + "message": { + "type": "string" + }, + "storage_uuid": { + "type": "string" + }, + "storage_type": { + "type": "string", + "enum": [ + "persistent", + "directory" + ] + }, + "frequency": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "save_s3": { + "type": "boolean" + }, + "disable_local_backup": { + "type": "boolean" + }, + "stop_during_backup": { + "type": "boolean" + }, + "s3_storage_uuid": { + "type": [ + "string", + "null" + ] + }, + "retention_amount_locally": { + "type": "integer" + }, + "retention_days_locally": { + "type": "integer" + }, + "retention_max_storage_locally": { + "type": "number", + "format": "float" + }, + "retention_amount_s3": { + "type": "integer" + }, + "retention_days_s3": { + "type": "integer" + }, + "retention_max_storage_s3": { + "type": "number", + "format": "float" + }, + "timeout": { + "type": "integer" + } + }, + "type": "object" + }, "Application": { "description": "Application model", "properties": { diff --git a/openapi.yaml b/openapi.yaml index 061940991..00cb59b7d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10047,6 +10047,168 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/storages/{storage_uuid}/backups': + put: + tags: + - Applications + summary: 'Set application storage backup schedule' + description: 'Create or replace the backup schedule for an application persistent volume or directory storage.' + operationId: set-application-storage-backup-schedule + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the persistent volume or directory storage.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleRequest' + responses: + '200': + description: 'Backup schedule replaced.' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleResponse' + '201': + description: 'Backup schedule created.' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleResponse' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/storages/{storage_uuid}/backups': + put: + tags: + - Databases + summary: 'Set database storage backup schedule' + description: 'Create or replace the backup schedule for a database persistent volume or directory storage.' + operationId: set-database-storage-backup-schedule + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the persistent volume or directory storage.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleRequest' + responses: + '200': + description: 'Backup schedule replaced.' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleResponse' + '201': + description: 'Backup schedule created.' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleResponse' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/storages/{storage_uuid}/backups': + put: + tags: + - Services + summary: 'Set service storage backup schedule' + description: 'Create or replace the backup schedule for a service persistent volume or directory storage.' + operationId: set-service-storage-backup-schedule + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the persistent volume or directory storage.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleRequest' + responses: + '200': + description: 'Backup schedule replaced.' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleResponse' + '201': + description: 'Backup schedule created.' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeBackupScheduleResponse' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /vultr/regions: get: tags: @@ -10136,6 +10298,130 @@ paths: bearerAuth: [] components: schemas: + VolumeBackupScheduleRequest: + required: + - frequency + properties: + frequency: + type: string + maxLength: 255 + example: '0 2 * * *' + enabled: + type: boolean + default: true + save_s3: + type: boolean + default: false + disable_local_backup: + type: boolean + default: false + stop_during_backup: + type: boolean + default: false + s3_storage_uuid: + type: + - string + - 'null' + retention_amount_locally: + type: integer + default: 7 + maximum: 10000 + minimum: 0 + retention_days_locally: + type: integer + default: 0 + maximum: 2147483647 + minimum: 0 + retention_max_storage_locally: + type: number + format: float + default: 0 + maximum: 9999999999 + minimum: 0 + retention_amount_s3: + type: integer + default: 7 + maximum: 10000 + minimum: 0 + retention_days_s3: + type: integer + default: 0 + maximum: 2147483647 + minimum: 0 + retention_max_storage_s3: + type: number + format: float + default: 0 + maximum: 9999999999 + minimum: 0 + timeout: + type: integer + default: 3600 + maximum: 36000 + minimum: 60 + type: object + additionalProperties: false + VolumeBackupScheduleResponse: + required: + - uuid + - message + - storage_uuid + - storage_type + - frequency + - enabled + - save_s3 + - disable_local_backup + - stop_during_backup + - retention_amount_locally + - retention_days_locally + - retention_max_storage_locally + - retention_amount_s3 + - retention_days_s3 + - retention_max_storage_s3 + - timeout + properties: + uuid: + type: string + message: + type: string + storage_uuid: + type: string + storage_type: + type: string + enum: + - persistent + - directory + frequency: + type: string + enabled: + type: boolean + save_s3: + type: boolean + disable_local_backup: + type: boolean + stop_during_backup: + type: boolean + s3_storage_uuid: + type: + - string + - 'null' + retention_amount_locally: + type: integer + retention_days_locally: + type: integer + retention_max_storage_locally: + type: number + format: float + retention_amount_s3: + type: integer + retention_days_s3: + type: integer + retention_max_storage_s3: + type: number + format: float + timeout: + type: integer + type: object Application: description: 'Application model' properties: diff --git a/resources/views/livewire/project/application/backup/create.blade.php b/resources/views/livewire/project/application/backup/create.blade.php index d2c739b07..8beb6e539 100644 --- a/resources/views/livewire/project/application/backup/create.blade.php +++ b/resources/views/livewire/project/application/backup/create.blade.php @@ -1,11 +1,11 @@
- @if ($volumes->isEmpty()) -
Add a persistent volume before configuring a backup.
+ @if ($targets->isEmpty()) +
Add a persistent volume or directory mount before configuring a backup.
@else
- - @foreach ($volumes as $volume) - + + @foreach ($targets as $target) + @endforeach
-
-

S3

- @if ($definedS3s->isEmpty()) -
No validated S3 storages found.
- @else -
- - @if ($saveToS3) - - @foreach ($definedS3s as $s3) - - @endforeach - - @endif -
- @endif -
- Save @endif
diff --git a/resources/views/livewire/project/application/backup/index.blade.php b/resources/views/livewire/project/application/backup/index.blade.php index 87e473e29..2ff16d355 100644 --- a/resources/views/livewire/project/application/backup/index.blade.php +++ b/resources/views/livewire/project/application/backup/index.blade.php @@ -1,13 +1,14 @@
@@ -28,7 +29,7 @@
- +
@@ -37,7 +38,7 @@
@forelse ($backups as $backup) @php($latestExecution = $backup->latestExecution) - $latestExecution?->status === 'running', 'border-error' => $latestExecution?->status === 'failed', @@ -66,7 +67,7 @@ @endif
- Volume: {{ $backup->volume->name }} + {{ $backup->targetType() }}: {{ $backup->targetName() }} @if ($latestExecution?->finished_at) • Last run {{ $latestExecution->finished_at->diffForHumans() }} @else diff --git a/resources/views/livewire/project/application/backup/show.blade.php b/resources/views/livewire/project/application/backup/show.blade.php index 9501d473c..7c9fbd828 100644 --- a/resources/views/livewire/project/application/backup/show.blade.php +++ b/resources/views/livewire/project/application/backup/show.blade.php @@ -1,11 +1,37 @@
- {{ data_get_str($application, 'name')->limit(10) }} > Volume Backups | Coolify + {{ data_get_str($application, 'name')->limit(10) }} > Storage Backups | Coolify -

Volume Backups

+

Storage Backups

- +
diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 691a90d5e..3d9f23cbb 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -14,7 +14,7 @@ [ 'label' => 'Backups', 'route' => 'project.application.backup.index', - 'active' => request()->routeIs('project.application.backup.index', 'project.application.backup.show'), + 'active' => request()->routeIs('project.application.backup.*'), ], [ 'label' => 'Logs', @@ -452,7 +452,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow- href="{{ route('project.application.deployment.index', $parameters) }}"> Deployments - diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index c7ef832f8..3c2868a8d 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -1,160 +1,11 @@ -
-
-

Scheduled Backup

-
- - Save - - @if (!$backupEnabled) - Enable Backup - @else - Disable Backup - @endif - @if (str($status)->startsWith('running')) - Backup Now - @endif - @if ($backup->database_id !== 0) -
- - - Delete Backups and Schedule - - -
- @endif -
-
-
- @if ($availableS3Storages->count() > 0) - - @elseif ($saveS3) - - @else - - @endif - @if ($saveS3) - - @else - - @endif -
-
-
- S3 Storage - @if (!$saveS3) - (currently disabled) - @endif - @if ($saveS3) - - @endif -
- - @if ($availableS3Storages->isEmpty()) - - @else - @foreach ($availableS3Storages as $s3) - - @endforeach - @endif - -
-
-

Settings

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

Backup Retention Settings

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

Local Backup Retention

-
- - - -
-
- - @if ($saveS3) -
-

S3 Storage Retention

-
- - - -
-
- @endif -
-
-
+
+ @if ($section === 'retention') + @include('livewire.project.database.backup-edit.retention') + @elseif ($section === 's3') + @include('livewire.project.database.backup-edit.s3') + @elseif ($section === 'danger') + @include('livewire.project.database.backup-edit.danger') + @else + @include('livewire.project.database.backup-edit.general') + @endif +
diff --git a/resources/views/livewire/project/database/backup-edit/danger.blade.php b/resources/views/livewire/project/database/backup-edit/danger.blade.php new file mode 100644 index 000000000..2dd60ea7b --- /dev/null +++ b/resources/views/livewire/project/database/backup-edit/danger.blade.php @@ -0,0 +1,27 @@ +
+
+

Danger Zone

+
Woah. I hope you know what you are doing.
+
+ +
+

Delete Scheduled Backup

+
+ This permanently deletes the schedule. You can also delete its associated backup archives. There is no coming back. +
+ @if ($backup->database_id !== 0) + + + Delete Backups and Schedule + + + @endif +
+
diff --git a/resources/views/livewire/project/database/backup-edit/general.blade.php b/resources/views/livewire/project/database/backup-edit/general.blade.php new file mode 100644 index 000000000..a9052118a --- /dev/null +++ b/resources/views/livewire/project/database/backup-edit/general.blade.php @@ -0,0 +1,62 @@ +
+
+

General

+
+ Save + @if (!$backupEnabled) + Enable Backup + @else + Disable Backup + @endif + @if (str($status)->startsWith('running')) + Backup Now + @endif +
+
+ +
+
+ @if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0) +
+ +
+ @if (!$backup->dump_all) + + @endif + @elseif ($backup->database_type === 'App\Models\StandaloneMongodb') + + @elseif ($backup->database_type === 'App\Models\StandaloneMysql') +
+ +
+ @if (!$backup->dump_all) + + @endif + @elseif ($backup->database_type === 'App\Models\StandaloneMariadb') +
+ +
+ @if (!$backup->dump_all) + + @endif + @endif +
+
+ + + +
+
+
diff --git a/resources/views/livewire/project/database/backup-edit/retention.blade.php b/resources/views/livewire/project/database/backup-edit/retention.blade.php new file mode 100644 index 000000000..91b9bf464 --- /dev/null +++ b/resources/views/livewire/project/database/backup-edit/retention.blade.php @@ -0,0 +1,43 @@ +
+
+

Retention

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

Local Backup Retention

+
+ + + +
+
+ +
+

S3 Storage Retention

+
+ + + +
+
+
+
diff --git a/resources/views/livewire/project/database/backup-edit/s3.blade.php b/resources/views/livewire/project/database/backup-edit/s3.blade.php new file mode 100644 index 000000000..e8405170a --- /dev/null +++ b/resources/views/livewire/project/database/backup-edit/s3.blade.php @@ -0,0 +1,45 @@ +
+
+

S3

+ Save + @if (!$saveS3) + Enable S3 + @else + Disable S3 + @endif +
+ +
+
+ S3 Storage + @if (!$saveS3) + (currently disabled) + @else + + @endif +
+ + @if ($availableS3Storages->isEmpty()) + + @else + @foreach ($availableS3Storages as $s3) + + @endforeach + @endif + +
+ +
+ @if ($saveS3) + + @else + + @endif +
+ +
diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index 0b7a9724a..3870a70b2 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -1,7 +1,7 @@
@isset($backup) -
-

Executions ({{ $executions_count }})

+
+

Executions

@if ($executions_count > 0)
@@ -36,7 +36,7 @@
+ class="flex flex-col gap-4 pt-2"> @forelse($executions as $execution)
Backups - diff --git a/resources/views/livewire/project/database/create-scheduled-backup.blade.php b/resources/views/livewire/project/database/create-scheduled-backup.blade.php index 8fa9924b9..0ec042718 100644 --- a/resources/views/livewire/project/database/create-scheduled-backup.blade.php +++ b/resources/views/livewire/project/database/create-scheduled-backup.blade.php @@ -2,19 +2,6 @@ -

S3

- @if ($definedS3s->count() === 0) -
No validated S3 Storages found.
- @else - - @if ($saveToS3) - - @foreach ($definedS3s as $s3) - - @endforeach - - @endif - @endif Save diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index 419387e58..bae7151e3 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -5,7 +5,7 @@ [ 'label' => 'Backups', 'route' => 'project.database.backup.index', - 'active' => request()->routeIs('project.database.backup.index', 'project.database.backup.execution'), + 'active' => request()->routeIs('project.database.backup.*'), 'visible' => in_array($database->getMorphClass(), [ 'App\Models\StandalonePostgresql', 'App\Models\StandaloneMongodb', @@ -195,7 +195,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow- $database->getMorphClass() === 'App\Models\StandaloneMongodb' || $database->getMorphClass() === 'App\Models\StandaloneMysql' || $database->getMorphClass() === 'App\Models\StandaloneMariadb') - Backups diff --git a/resources/views/livewire/project/database/scheduled-backups.blade.php b/resources/views/livewire/project/database/scheduled-backups.blade.php index c522cd666..963ba232d 100644 --- a/resources/views/livewire/project/database/scheduled-backups.blade.php +++ b/resources/views/livewire/project/database/scheduled-backups.blade.php @@ -123,10 +123,8 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
@else -
- data_get($backup, 'id') === data_get($selectedBackup, 'id'), 'border-blue-500/50 border-dashed' => $backup->latest_log && data_get($backup->latest_log, 'status') === 'running', @@ -137,9 +135,8 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray- $backup->latest_log && data_get($backup->latest_log, 'status') === 'success', 'border-gray-200 dark:border-coolgray-300' => !$backup->latest_log, - 'border-coollabs' => - data_get($backup, 'id') === data_get($selectedBackup, 'id'), - ]) wire:click="setSelectedBackup('{{ data_get($backup, 'id') }}')"> + ]) {{ wireNavigate() }} + href="{{ route('project.service.database.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"> @if ($backup->latest_log && data_get($backup->latest_log, 'status') === 'running')
@@ -224,19 +221,11 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray- @endif @endif
-
+ @endif @empty
No scheduled backups configured.
@endforelse @endif
- @if ($type === 'service-database' && $selectedBackup) -
- - -
- @endif
diff --git a/resources/views/livewire/project/service/database-backups.blade.php b/resources/views/livewire/project/service/database-backups.blade.php index 296289178..9eba9af2e 100644 --- a/resources/views/livewire/project/service/database-backups.blade.php +++ b/resources/views/livewire/project/service/database-backups.blade.php @@ -1,23 +1,65 @@
- + @if ($backup) + + @else + + @endif +
{{ data_get_str($service, 'name')->limit(10) }} > {{ data_get_str($serviceDatabase, 'name')->limit(10) }} > Backups | Coolify -
-

Scheduled Backups

- @if (filled($serviceDatabase->custom_type) || !$serviceDatabase->is_migrated) - @can('update', $serviceDatabase) - - - - @endcan + + @if ($backup) + @if ($section === 'executions') + + @else + @endif -
- + @else +
+

Scheduled Backups

+ @if (filled($serviceDatabase->custom_type) || !$serviceDatabase->is_migrated) + @can('update', $serviceDatabase) + + + + @endcan + @endif +
+ + @endif
diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index e47d290b8..5479eea35 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -19,7 +19,15 @@ @endif
- + + + @if ($hasEnabledBackup) + + @endif + +
@@ -51,6 +59,14 @@ confirmationText="{{ $fs_path }}" confirmationLabel="Please confirm the execution of the actions by entering the Filepath below" shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" /> + @if ($resource instanceof \App\Models\Application) + + + + @endif + + +
+ @endcan + @endif
diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index 820d99080..c8933781d 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -23,7 +23,7 @@ ['label' => 'Back', 'route' => 'project.service.configuration', 'active' => false, 'parameters' => [...$parameters, 'stack_service_uuid' => null]], ['label' => 'General', 'route' => 'project.service.index', 'active' => request()->routeIs('project.service.index')], ['label' => 'Advanced', 'route' => 'project.service.index.advanced', 'active' => request()->routeIs('project.service.index.advanced')], - ['label' => 'Backups', 'route' => 'project.service.database.backups', 'active' => request()->routeIs('project.service.database.backups')], + ['label' => 'Backups', 'route' => 'project.service.database.backups', 'active' => request()->routeIs('project.service.database.backup*')], ['label' => 'Import Backup', 'route' => 'project.service.database.import', 'active' => request()->routeIs('project.service.database.import')], ]; } diff --git a/resources/views/livewire/project/shared/configuration-checker.blade.php b/resources/views/livewire/project/shared/configuration-checker.blade.php index 19974c587..8b0e8298d 100644 --- a/resources/views/livewire/project/shared/configuration-checker.blade.php +++ b/resources/views/livewire/project/shared/configuration-checker.blade.php @@ -55,6 +55,7 @@ class="relative flex max-h-[85vh] w-full flex-col rounded-sm border border-neutr

Configuration changes

These changes are not applied to the latest deployment yet. + (If you think it is incorrect, just ignore)