From 63961e0799fa5730d6854d30bbfd150fc33e3e77 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:47:57 +0200 Subject: [PATCH 01/17] feat(backups): add scheduled persistent volume backups --- app/Jobs/ScheduledJobManager.php | 107 ++ app/Jobs/VolumeBackupJob.php | 414 +++++++ app/Jobs/VolumeBackupRecoveryJob.php | 117 ++ .../Project/Application/Backup/Create.php | 130 ++ .../Project/Application/Backup/Index.php | 55 + .../Project/Application/Backup/Show.php | 46 + app/Livewire/Project/Database/BackupEdit.php | 13 + app/Livewire/Project/Shared/Storages/Show.php | 20 +- .../Project/Shared/Storages/VolumeBackups.php | 394 ++++++ app/Livewire/Storage/Show.php | 4 +- app/Models/LocalPersistentVolume.php | 6 + app/Models/S3Storage.php | 15 + app/Models/ScheduledVolumeBackup.php | 82 ++ app/Models/ScheduledVolumeBackupExecution.php | 43 + app/View/Components/Forms/Checkbox.php | 1 + bootstrap/helpers/databases.php | 10 +- ..._create_scheduled_volume_backups_table.php | 39 + ...heduled_volume_backup_executions_table.php | 42 + ...mits_to_scheduled_volume_backups_table.php | 30 + .../views/components/forms/checkbox.blade.php | 3 +- .../views/components/forms/input.blade.php | 3 + .../application/backup/create.blade.php | 36 + .../application/backup/index.blade.php | 88 ++ .../project/application/backup/show.blade.php | 11 + .../project/application/heading.blade.php | 9 + .../project/database/backup-edit.blade.php | 11 +- .../project/database/heading.blade.php | 24 +- .../database/scheduled-backups.blade.php | 24 +- .../project/shared/storages/show.blade.php | 74 +- .../shared/storages/volume-backups.blade.php | 338 +++++ .../views/livewire/storage/show.blade.php | 2 +- routes/web.php | 67 + tests/Feature/BackupEditValidationTest.php | 53 + tests/Feature/BackupSearchTest.php | 121 ++ tests/Feature/MobileResourceMenuTest.php | 13 + tests/Feature/VolumeBackupTest.php | 1092 +++++++++++++++++ 36 files changed, 3506 insertions(+), 31 deletions(-) create mode 100644 app/Jobs/VolumeBackupJob.php create mode 100644 app/Jobs/VolumeBackupRecoveryJob.php create mode 100644 app/Livewire/Project/Application/Backup/Create.php create mode 100644 app/Livewire/Project/Application/Backup/Index.php create mode 100644 app/Livewire/Project/Application/Backup/Show.php create mode 100644 app/Livewire/Project/Shared/Storages/VolumeBackups.php create mode 100644 app/Models/ScheduledVolumeBackup.php create mode 100644 app/Models/ScheduledVolumeBackupExecution.php create mode 100644 database/migrations/2026_07_15_102537_create_scheduled_volume_backups_table.php create mode 100644 database/migrations/2026_07_15_102538_create_scheduled_volume_backup_executions_table.php create mode 100644 database/migrations/2026_07_15_125721_add_retention_limits_to_scheduled_volume_backups_table.php create mode 100644 resources/views/livewire/project/application/backup/create.blade.php create mode 100644 resources/views/livewire/project/application/backup/index.blade.php create mode 100644 resources/views/livewire/project/application/backup/show.blade.php create mode 100644 resources/views/livewire/project/shared/storages/volume-backups.blade.php create mode 100644 tests/Feature/BackupSearchTest.php create mode 100644 tests/Feature/VolumeBackupTest.php diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 46bc89d42..92a0db748 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -4,6 +4,8 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledTask; +use App\Models\ScheduledVolumeBackup; +use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\Team; use Cron\CronExpression; @@ -109,6 +111,16 @@ public function handle(): void ]); } + try { + $this->recoverPausedVolumeBackupContainers(); + $this->processScheduledVolumeBackups(); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Failed to process scheduled volume backups', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + // Process Docker cleanups - don't let failures stop the job manager try { $this->processDockerCleanups(); @@ -341,6 +353,101 @@ private function processScheduledTask(ScheduledTask $task, ?Server $precheckedSe } } + private function processScheduledVolumeBackups(): void + { + ScheduledVolumeBackup::query() + ->with(['volume', 'team.subscription']) + ->where('enabled', true) + ->chunkById(self::CHUNK_SIZE, function ($backups): void { + foreach ($backups as $backup) { + $this->processScheduledVolumeBackup($backup); + } + }); + } + + private function recoverPausedVolumeBackupContainers(): void + { + ScheduledVolumeBackupExecution::query() + ->where(fn (Builder $query) => $query + ->where('pause_recovery_pending', true) + ->orWhere('s3_cleanup_pending', true)) + ->chunkById(self::CHUNK_SIZE, function ($executions): void { + foreach ($executions as $execution) { + VolumeBackupRecoveryJob::dispatch($execution); + } + }); + } + + private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): void + { + try { + $server = $backup->server(); + + if ($backup->executions() + ->where(fn (Builder $query) => $query + ->where('pause_recovery_pending', true) + ->orWhere('s3_cleanup_pending', true)) + ->exists()) { + $this->skippedCount++; + $this->logSkip('volume_backup', 'container_recovery_pending', [ + 'backup_id' => $backup->id, + 'team_id' => $backup->team_id, + ]); + + return; + } + + if (! $backup->volume || ! $server) { + $backup->delete(); + $this->skippedCount++; + $this->logSkip('volume_backup', 'resource_deleted', [ + 'backup_id' => $backup->id, + 'team_id' => $backup->team_id, + ]); + + return; + } + + if (! $server->isFunctional()) { + $this->skippedCount++; + $this->logSkip('volume_backup', 'server_not_functional', [ + 'backup_id' => $backup->id, + 'team_id' => $backup->team_id, + 'server_id' => $server->id, + ]); + + return; + } + + if (isCloud() && $backup->team_id !== 0 && ! data_get($backup, 'team.subscription.stripe_invoice_paid', false)) { + $this->skippedCount++; + $this->logSkip('volume_backup', 'subscription_unpaid', [ + 'backup_id' => $backup->id, + 'team_id' => $backup->team_id, + 'server_id' => $server->id, + ]); + + return; + } + + if ($this->shouldDispatch($backup->frequency, $server, "scheduled-volume-backup:{$backup->id}")) { + VolumeBackupJob::dispatch($backup); + $this->dispatchedCount++; + Log::channel('scheduled')->info('Volume backup dispatched', [ + 'backup_id' => $backup->id, + 'volume_id' => $backup->local_persistent_volume_id, + 'team_id' => $backup->team_id, + 'server_id' => $server->id, + ]); + } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing volume backup', [ + 'backup_id' => $backup->id, + 'error' => $e->getMessage(), + ]); + } + } + private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string { if (blank(data_get($backup, 'database'))) { diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php new file mode 100644 index 000000000..fe1668001 --- /dev/null +++ b/app/Jobs/VolumeBackupJob.php @@ -0,0 +1,414 @@ +onQueue(crons_queue()); + $this->timeout = $backup->timeout ?? 3600; + } + + public function middleware(): array + { + return [ + (new WithoutOverlapping('volume-backup-'.$this->backup->id)) + ->shared() + ->expireAfter($this->timeout + 60) + ->dontRelease(), + ]; + } + + public static function lockKey(int $backupId): string + { + return 'laravel-queue-overlap:volume-backup-'.$backupId; + } + + public function handle(): void + { + $this->backup->loadMissing(['volume.resource', 'team', 's3']); + $server = $this->backup->server(); + $volume = $this->backup->volume; + $team = $this->backup->team; + + if (! $server || ! $volume || ! $team) { + throw new \RuntimeException('The volume 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'; + $backupLocation = $backupDirectory.'/'.$filename; + $this->execution->update(['filename' => $backupLocation]); + + try { + $source = filled($volume->host_path) ? $volume->host_path : $volume->name; + $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'; + + $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image) + .' tar -czf - -C /volume . > '.escapeshellarg($backupLocation); + + if ($this->backup->pause_during_backup) { + $containers = $this->containersUsingVolume($source, $server); + if ($containers !== []) { + $this->execution->update(['pause_recovery_pending' => true]); + $archiveCommand = $this->archiveWithPausedContainers( + $archiveCommand, + $containers, + VolumeBackupRecoveryJob::stateFile($this->execution), + ); + } + } + + instant_remote_process([ + $verifySourceCommand, + 'mkdir -p '.escapeshellarg($backupDirectory), + $archiveCommand, + ], $server, timeout: $this->timeout, disableMultiplexing: true); + $this->execution->update([ + 'pause_container_ids' => null, + 'pause_recovery_pending' => false, + ]); + + $size = (int) instant_remote_process( + ['du -b '.escapeshellarg($backupLocation).' | cut -f1'], + $server, + disableMultiplexing: true, + ); + + if ($size <= 0) { + throw new \RuntimeException('The volume backup archive is empty or was not created.'); + } + + $warning = null; + $s3Uploaded = null; + $s3CleanupPending = false; + $localStorageDeleted = false; + + if ($this->backup->save_s3) { + $s3CleanupPending = true; + $this->execution->update(['s3_cleanup_pending' => true]); + + try { + $this->uploadToS3($backupLocation, $backupDirectory, $server); + $s3Uploaded = true; + $s3CleanupPending = false; + } catch (Throwable $exception) { + $s3Uploaded = false; + $warning = 'S3 upload failed: '.$exception->getMessage(); + + try { + VolumeBackupRecoveryJob::cleanupS3Upload($this->execution); + $s3CleanupPending = false; + } catch (Throwable $cleanupException) { + VolumeBackupRecoveryJob::dispatch($this->execution); + $warning .= ' Partial S3 upload cleanup failed: '.$cleanupException->getMessage(); + } + } + + if ($s3Uploaded && $this->backup->disable_local_backup) { + try { + deleteBackupsLocally($backupLocation, $server, throwError: true); + $localStorageDeleted = true; + } catch (Throwable $exception) { + $warning = 'S3 upload succeeded, but the local archive could not be deleted: '.$exception->getMessage(); + } + } + } + + $this->execution->update([ + 'status' => 'success', + 'message' => $warning, + 'size' => $size, + 'filename' => $backupLocation, + 's3_uploaded' => $s3Uploaded, + 's3_cleanup_pending' => $s3CleanupPending, + 'local_storage_deleted' => $localStorageDeleted, + ]); + + try { + $this->removeExpiredBackups($server); + } catch (Throwable $exception) { + Log::channel('scheduled-errors')->warning('Volume backup retention cleanup failed', [ + 'backup_id' => $this->backup->id, + 'execution_id' => $this->execution->id, + 'error' => $exception->getMessage(), + ]); + } + } catch (Throwable $exception) { + $recoveryError = $this->recoverIncompleteBackup($this->execution); + $archiveDeleted = false; + + try { + deleteBackupsLocally($backupLocation, $server, throwError: true); + $archiveDeleted = true; + } catch (Throwable $cleanupException) { + $recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); + } + + $s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending; + + $this->execution->update([ + 'status' => 'failed', + 'message' => $exception->getMessage().$recoveryError, + 'filename' => $archiveDeleted && ! $s3CleanupPending ? null : $backupLocation, + 'local_storage_deleted' => $archiveDeleted, + ]); + + throw $exception; + } finally { + $this->execution->update(['finished_at' => now()]); + BackupCreated::dispatch($team->id); + } + } + + public function failed(?Throwable $exception): void + { + $execution = $this->execution ?? $this->backup->executions() + ->where('status', 'running') + ->latest('id') + ->first(); + + if ($execution) { + $recoveryError = $this->recoverIncompleteBackup($execution); + $server = $this->backup->server(); + $filename = $execution->filename; + $localStorageDeleted = $execution->local_storage_deleted; + + if ($server && filled($filename)) { + try { + deleteBackupsLocally($filename, $server, throwError: true); + $localStorageDeleted = true; + if (! $execution->fresh()->s3_cleanup_pending) { + $filename = null; + } + } catch (Throwable $cleanupException) { + $recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); + } + } + + $execution->update([ + 'status' => 'failed', + 'message' => ($exception?->getMessage() ?? 'Volume backup timed out or was terminated.').$recoveryError, + 'finished_at' => now(), + 'filename' => $filename, + 'local_storage_deleted' => $localStorageDeleted, + ]); + } + } + + /** + * @return array + */ + private function containersUsingVolume(string $source, Server $server): array + { + $output = instant_remote_process( + ["containers=\$(docker ps -q) || exit 1; for container in \$containers; do paused=\$(docker inspect --format '{{.State.Paused}}' \"\$container\") || exit 1; [ \"\$paused\" = true ] && continue; mounts=\$(docker inspect --format '{{range .Mounts}}{{println .Source}}{{println .Name}}{{end}}' \"\$container\") || exit 1; if printf '%s\\n' \"\$mounts\" | grep -Fqx -- ".escapeshellarg($source).'; then echo "$container"; fi; done'], + $server, + disableMultiplexing: true, + ); + $containers = collect(preg_split('/\s+/', trim((string) $output))) + ->filter(fn (string $container): bool => preg_match('/^[a-f0-9]{6,64}$/i', $container) === 1) + ->values() + ->all(); + + return $containers; + } + + /** + * @param array $containers + */ + private function archiveWithPausedContainers(string $archiveCommand, array $containers, string $stateFile): string + { + if ($containers === []) { + return $archiveCommand; + } + + $containerList = implode(' ', $containers); + $script = "set -eu\n" + .'state_file='.escapeshellarg($stateFile)."\n" + .": > \"\$state_file\"\n" + ."cleanup() { status=\$?; trap - EXIT HUP INT TERM; while IFS= read -r container; do docker unpause \"\$container\" >/dev/null || status=1; done < \"\$state_file\"; [ \$status -eq 0 ] && rm -f \"\$state_file\"; exit \$status; }\n" + ."trap cleanup EXIT HUP INT TERM\n" + ."for container in {$containerList}; do echo \"\$container\" >> \"\$state_file\"; docker pause \"\$container\" >/dev/null; done\n" + .$archiveCommand; + + return 'sh -c '.escapeshellarg($script); + } + + private function recoverIncompleteBackup(ScheduledVolumeBackupExecution $execution): string + { + if (! $execution->pause_recovery_pending && ! $execution->s3_cleanup_pending) { + return ''; + } + + try { + VolumeBackupRecoveryJob::recover($execution); + + return ''; + } catch (Throwable $exception) { + VolumeBackupRecoveryJob::dispatch($execution); + + return ' Container recovery failed: '.$exception->getMessage(); + } + } + + private function uploadToS3(string $backupLocation, string $backupDirectory, Server $server): void + { + $s3 = $this->backup->s3; + + if (! $s3) { + $this->backup->update(['save_s3' => false, 's3_storage_id' => null]); + + throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.'); + } + + $s3->testConnection(shouldSave: true); + $containerName = 'volume-upload-'.$this->execution->uuid; + $image = coolifyHelperImage().':'.getHelperVersion(); + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint)) + ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) + ->implode(' '); + $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + + try { + instant_remote_process([ + 'docker rm -f '.escapeshellarg($containerName).' >/dev/null 2>&1 || true', + 'docker run -d --name '.escapeshellarg($containerName).' --rm -v ' + .escapeshellarg($backupLocation.':'.$backupLocation.':ro').' '.escapeshellarg($image), + 'docker exec '.escapeshellarg($containerName).' mc alias set'.$resolveOptions.' temporary ' + .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret), + 'docker exec '.escapeshellarg($containerName).' mc cp '.escapeshellarg($backupLocation).' ' + .escapeshellarg('temporary/'.$s3->bucket.$backupDirectory.'/'), + ], $server, timeout: $this->timeout, disableMultiplexing: true); + } finally { + instant_remote_process( + ['docker rm -f '.escapeshellarg($containerName)], + $server, + throwError: false, + disableMultiplexing: true, + ); + } + } + + private function removeExpiredBackups(Server $server): void + { + $localExecutions = $this->backup->executions() + ->where('status', 'success') + ->where('local_storage_deleted', false) + ->get(); + $localExecutions = $this->executionsOutsideRetention( + $localExecutions, + $this->backup->retention_amount_locally, + $this->backup->retention_days_locally, + $this->backup->retention_max_storage_locally, + ); + + $filenames = $localExecutions->pluck('filename')->filter()->all(); + if ($filenames !== []) { + deleteBackupsLocally($filenames, $server, throwError: true); + $this->backup->executions()->whereKey($localExecutions->pluck('id')->all()) + ->update(['local_storage_deleted' => true]); + } + + if ($this->backup->save_s3 && $this->backup->s3) { + $s3Executions = $this->backup->executions() + ->where('status', 'success') + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false) + ->get(); + $s3Executions = $this->executionsOutsideRetention( + $s3Executions, + $this->backup->retention_amount_s3, + $this->backup->retention_days_s3, + $this->backup->retention_max_storage_s3, + ); + + $filenames = $s3Executions->pluck('filename')->filter()->all(); + if ($filenames !== []) { + deleteBackupsS3($filenames, $this->backup->s3); + $this->backup->executions()->whereKey($s3Executions->pluck('id')->all()) + ->update(['s3_storage_deleted' => true]); + } + } + + $this->backup->executions() + ->where('local_storage_deleted', true) + ->where(function (Builder $query): void { + $query->where('s3_storage_deleted', true)->orWhereNull('s3_uploaded'); + }) + ->delete(); + } + + private function executionsOutsideRetention(Collection $executions, int $amount, int $days, float $maxStorageGb): Collection + { + if ($amount === 0 && $days === 0 && $maxStorageGb == 0) { + return collect(); + } + + $executionsToDelete = collect(); + + if ($amount > 0) { + $executionsToDelete = $executionsToDelete->merge($executions->skip($amount)); + } + + if ($days > 0) { + $oldestAllowedDate = now()->subDays($days); + $executionsToDelete = $executionsToDelete->merge( + $executions->filter(fn (ScheduledVolumeBackupExecution $execution): bool => $execution->created_at->isBefore($oldestAllowedDate)), + ); + } + + if ($maxStorageGb > 0) { + $maxStorageBytes = $maxStorageGb * 1024 ** 3; + $totalSize = 0; + + foreach ($executions as $index => $execution) { + $totalSize += (int) $execution->size; + + if ($index > 0 && $totalSize > $maxStorageBytes) { + $executionsToDelete = $executionsToDelete->merge($executions->slice($index)); + + break; + } + } + } + + return $executionsToDelete->unique('id')->values(); + } +} diff --git a/app/Jobs/VolumeBackupRecoveryJob.php b/app/Jobs/VolumeBackupRecoveryJob.php new file mode 100644 index 000000000..1749b5309 --- /dev/null +++ b/app/Jobs/VolumeBackupRecoveryJob.php @@ -0,0 +1,117 @@ +onQueue(crons_queue()); + } + + public function middleware(): array + { + return [ + (new WithoutOverlapping('volume-backup-'.$this->execution->scheduled_volume_backup_id)) + ->shared() + ->expireAfter(300) + ->dontRelease(), + (new WithoutOverlapping('volume-backup-recovery-'.$this->execution->id)) + ->expireAfter(300) + ->dontRelease(), + ]; + } + + public function handle(): void + { + self::recover($this->execution); + } + + public static function recover(ScheduledVolumeBackupExecution $execution): void + { + $execution->loadMissing('scheduledVolumeBackup.volume.resource'); + + if ($execution->pause_recovery_pending) { + self::recoverContainers($execution); + } + + if ($execution->s3_cleanup_pending) { + self::cleanupS3Upload($execution); + } + } + + private static function recoverContainers(ScheduledVolumeBackupExecution $execution): void + { + $server = $execution->scheduledVolumeBackup?->server(); + + if (! $server) { + throw new \RuntimeException('The server is unavailable for container recovery.'); + } + + $stateFile = self::stateFile($execution); + $output = instant_remote_process( + ['cat '.escapeshellarg($stateFile).' 2>/dev/null || true'], + $server, + disableMultiplexing: true, + ); + $containers = collect(preg_split('/\s+/', trim((string) $output))) + ->filter(fn (string $container): bool => preg_match('/^[a-f0-9]{6,64}$/i', $container) === 1) + ->values() + ->all(); + $execution->update(['pause_container_ids' => $containers]); + + $remainingFile = $stateFile.'.remaining'; + $script = 'status=0; : > '.escapeshellarg($remainingFile).'; ' + .'if [ -f '.escapeshellarg($stateFile).' ]; then while IFS= read -r container; do ' + .'[ -z "$container" ] && continue; paused=$(docker inspect --format \'{{.State.Paused}}\' "$container" 2>/dev/null) ' + .'|| { echo "$container" >> '.escapeshellarg($remainingFile).'; status=1; continue; }; ' + .'if [ "$paused" = true ] && ! docker unpause "$container" >/dev/null; then echo "$container" >> ' + .escapeshellarg($remainingFile).'; status=1; fi; done < '.escapeshellarg($stateFile).'; fi; ' + .'if [ -s '.escapeshellarg($remainingFile).' ]; then mv '.escapeshellarg($remainingFile).' '.escapeshellarg($stateFile) + .'; else rm -f '.escapeshellarg($stateFile).' '.escapeshellarg($remainingFile).'; fi; exit $status'; + + instant_remote_process(['sh -c '.escapeshellarg($script)], $server, disableMultiplexing: true); + $execution->update([ + 'pause_container_ids' => null, + 'pause_recovery_pending' => false, + ]); + } + + public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution): void + { + $execution->loadMissing('scheduledVolumeBackup.s3'); + $s3 = $execution->scheduledVolumeBackup?->s3; + + if (! $s3 || blank($execution->filename)) { + throw new \RuntimeException('The S3 storage or backup filename is unavailable for upload cleanup.'); + } + + deleteBackupsS3($execution->filename, $s3); + $execution->update([ + 's3_cleanup_pending' => false, + 's3_storage_deleted' => true, + ]); + } + + public static function stateFile(ScheduledVolumeBackupExecution $execution): string + { + return '/tmp/coolify-volume-backup-'.$execution->uuid.'.paused'; + } +} diff --git a/app/Livewire/Project/Application/Backup/Create.php b/app/Livewire/Project/Application/Backup/Create.php new file mode 100644 index 000000000..c69421778 --- /dev/null +++ b/app/Livewire/Project/Application/Backup/Create.php @@ -0,0 +1,130 @@ + ['required', 'integer'], + '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; + $this->loadSelectedBackup(); + } + + public function updatedVolumeId(): void + { + $this->loadSelectedBackup(); + } + + 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.'); + + return; + } + + if (! validate_cron_expression($this->frequency)) { + $this->addError('frequency', 'The frequency must be a valid cron or human expression.'); + + 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], + [ + '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'); + } + + public function render() + { + return view('livewire.project.application.backup.create'); + } + + private function loadSelectedBackup(): void + { + $volume = $this->volumes->firstWhere('id', $this->volumeId); + if (! $volume) { + return; + } + + $backup = $volume->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 + { + return $this->s3StorageId !== null + && S3Storage::ownedByCurrentTeam() + ->where('is_usable', true) + ->whereKey($this->s3StorageId) + ->exists(); + } +} diff --git a/app/Livewire/Project/Application/Backup/Index.php b/app/Livewire/Project/Application/Backup/Index.php new file mode 100644 index 000000000..e5dd3c304 --- /dev/null +++ b/app/Livewire/Project/Application/Backup/Index.php @@ -0,0 +1,55 @@ + '$refresh']; + + public function mount(): void + { + $this->application = $this->findApplication(); + $this->authorize('view', $this->application); + $this->parameters = get_route_parameters(); + } + + public function render(): View + { + $volumeIds = $this->application->persistentStorages()->pluck('id'); + $backups = ScheduledVolumeBackup::query() + ->with(['volume', 'latestExecution']) + ->withCount('executions') + ->whereIn('local_persistent_volume_id', $volumeIds) + ->latest() + ->get(); + + return view('livewire.project.application.backup.index', ['backups' => $backups]); + } + + private function findApplication(): Application + { + $project = currentTeam()->projects() + ->where('uuid', request()->route('project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->where('uuid', request()->route('environment_uuid')) + ->firstOrFail(); + + return $environment->applications() + ->with('destination.server') + ->where('uuid', request()->route('application_uuid')) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Project/Application/Backup/Show.php b/app/Livewire/Project/Application/Backup/Show.php new file mode 100644 index 000000000..f43593390 --- /dev/null +++ b/app/Livewire/Project/Application/Backup/Show.php @@ -0,0 +1,46 @@ +projects() + ->where('uuid', request()->route('project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->where('uuid', request()->route('environment_uuid')) + ->firstOrFail(); + $this->application = $environment->applications() + ->with('destination.server') + ->where('uuid', request()->route('application_uuid')) + ->firstOrFail(); + $this->authorize('view', $this->application); + + $this->backup = ScheduledVolumeBackup::query() + ->with('volume') + ->where('uuid', request()->route('backup_uuid')) + ->whereIn('local_persistent_volume_id', $this->application->persistentStorages()->pluck('id')) + ->firstOrFail(); + $this->parameters = get_route_parameters(); + } + + public function render() + { + return view('livewire.project.application.backup.show'); + } +} diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 1c1bea2f6..d8ce9c97f 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -217,6 +217,19 @@ public function instantSave() } } + public function toggleEnabled(): void + { + try { + $this->authorize('manageBackups', $this->backup->database); + + $this->backupEnabled = ! $this->backupEnabled; + $this->backup->update(['enabled' => $this->backupEnabled]); + $this->dispatch('success', $this->backupEnabled ? 'Backup enabled.' : 'Backup disabled.'); + } catch (\Throwable $e) { + $this->dispatch('error', $e->getMessage()); + } + } + public function updatedS3StorageId(): void { $this->instantSave(); diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 2aaca5e6f..66ab4a114 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -5,6 +5,7 @@ use App\Models\LocalPersistentVolume; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\On; use Livewire\Component; class Show extends Component @@ -32,6 +33,8 @@ class Show extends Component public bool $isPreviewSuffixEnabled = true; + public bool $hasEnabledBackup = false; + protected $validationAttributes = [ 'name' => 'name', 'mountPath' => 'mount', @@ -81,10 +84,19 @@ private function syncData(bool $toModel = false): void } } - public function mount() + public function mount(): void { $this->syncData(false); $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); + $this->refreshBackupStatus(); + } + + #[On('refreshVolumeBackups')] + public function refreshBackupStatus(): void + { + $this->hasEnabledBackup = $this->storage->scheduledBackups() + ->where('enabled', true) + ->exists(); } public function instantSave(): void @@ -115,6 +127,12 @@ public function delete($password, $selectedActions = []) return 'The provided password is incorrect.'; } + if ($this->storage->scheduledBackups()->exists()) { + $this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.'); + + return false; + } + $this->storage->delete(); $this->dispatch('refreshStorages'); diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php new file mode 100644 index 000000000..63441ba34 --- /dev/null +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -0,0 +1,394 @@ + ['required', 'string'], + 'enabled' => ['required', 'boolean'], + 'saveToS3' => ['required', 'boolean'], + 'disableLocalBackup' => ['required', 'boolean'], + 'pauseDuringBackup' => ['required', 'boolean'], + 's3StorageId' => ['nullable', 'integer'], + 'retentionAmountLocally' => ['required', 'integer', 'min:0', 'max:10000'], + 'retentionDaysLocally' => ['required', 'integer', 'min:0'], + 'retentionMaxStorageLocally' => ['required', 'numeric', 'min:0'], + 'retentionAmountS3' => ['required', 'integer', 'min:0', 'max:10000'], + 'retentionDaysS3' => ['required', 'integer', 'min:0'], + 'retentionMaxStorageS3' => ['required', 'numeric', 'min:0'], + 'timeout' => ['required', 'integer', 'min:60', 'max:36000'], + ]; + } + + public function mount(): void + { + $this->authorize('view', $this->resource); + $this->availableS3Storages = S3Storage::ownedByCurrentTeam() + ->where('is_usable', true) + ->get(); + $this->backup = $this->storage->scheduledBackups()->first(); + $server = $this->backup?->server() ?? data_get($this->resource, 'destination.server'); + $this->timezone = data_get($server, 'settings.server_timezone', 'Instance timezone'); + + if ($this->backup) { + $this->frequency = $this->backup->frequency; + $this->enabled = $this->backup->enabled; + $this->saveToS3 = $this->backup->save_s3; + $this->disableLocalBackup = $this->backup->disable_local_backup; + $this->pauseDuringBackup = $this->backup->pause_during_backup; + $this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3Storages->first()?->id; + $this->retentionAmountLocally = $this->backup->retention_amount_locally; + $this->retentionDaysLocally = $this->backup->retention_days_locally; + $this->retentionMaxStorageLocally = $this->backup->retention_max_storage_locally; + $this->retentionAmountS3 = $this->backup->retention_amount_s3; + $this->retentionDaysS3 = $this->backup->retention_days_s3; + $this->retentionMaxStorageS3 = $this->backup->retention_max_storage_s3; + $this->timeout = $this->backup->timeout; + } else { + $this->s3StorageId = $this->availableS3Storages->first()?->id; + } + } + + public function save(): void + { + $this->authorize('update', $this->resource); + + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); + $this->dispatch('success', 'Volume backup schedule saved.'); + } + + public function instantSave(): void + { + $this->save(); + } + + public function updatedS3StorageId(): void + { + if ($this->saveToS3) { + $this->save(); + } + } + + public function toggleEnabled(): void + { + $this->authorize('update', $this->resource); + + if (! $this->backup) { + if (! $this->validateSettings()) { + return; + } + + $this->enabled = true; + $this->backup = $this->persistBackup(true); + } else { + $this->enabled = ! $this->enabled; + $this->backup->update(['enabled' => $this->enabled]); + } + + $this->dispatch('success', $this->enabled ? 'Volume backups enabled.' : 'Volume backups disabled.'); + } + + public function backupNow(): void + { + $this->authorize('update', $this->resource); + + if (! $this->validateSettings()) { + return; + } + + if (! $this->backup) { + $this->enabled = false; + } + + $this->backup = $this->persistBackup($this->enabled); + VolumeBackupJob::dispatch($this->backup); + $this->dispatch('success', 'Volume backup queued.'); + } + + public function delete(?string $password = null, array $selectedActions = []) + { + $this->authorize('update', $this->resource); + + if (! $password || ! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + if (! $this->backup) { + return false; + } + + $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.'); + + return false; + } + + try { + if ($this->backup->executions() + ->where(fn ($query) => $query + ->where('status', 'running') + ->orWhere('pause_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.'); + + return false; + } + + $localFilenames = $this->backup->executions() + ->where('local_storage_deleted', false) + ->pluck('filename') + ->filter() + ->all(); + $server = $this->backup->server(); + + if ($localFilenames !== []) { + if (! $server) { + throw new \RuntimeException('The server is unavailable, so local backup archives cannot be deleted.'); + } + + deleteBackupsLocally($localFilenames, $server, throwError: true); + } + + $s3Filenames = $this->backup->executions() + ->where('s3_uploaded', true) + ->where('s3_storage_deleted', false) + ->pluck('filename') + ->filter() + ->all(); + + if ($s3Filenames !== []) { + if (! $this->backup->s3) { + throw new \RuntimeException('The S3 storage is unavailable, so remote backup archives cannot be deleted.'); + } + + deleteBackupsS3($s3Filenames, $this->backup->s3); + } + + $this->backup->delete(); + $this->backup = null; + $this->dispatch('success', 'Volume backup schedule and archives deleted.'); + + return true; + } catch (Throwable $exception) { + $this->dispatch('error', 'Could not delete the backup archives: '.$exception->getMessage()); + + return false; + } finally { + $lock->release(); + } + } + + public function cleanupFailed(): void + { + $this->authorize('update', $this->resource); + + $deletedCount = $this->backup?->executions() + ->where('status', 'failed') + ->where('pause_recovery_pending', false) + ->where('s3_cleanup_pending', false) + ->where(fn ($query) => $query + ->whereNull('filename') + ->orWhere('local_storage_deleted', true)) + ->delete() ?? 0; + + $this->dispatch( + $deletedCount > 0 ? 'success' : 'info', + $deletedCount > 0 ? 'Failed backup entries cleaned up.' : 'No safely removable failed backup entries found.', + ); + } + + public function cleanupDeleted(): void + { + $this->authorize('update', $this->resource); + + $deletedCount = $this->backup?->executions() + ->where('local_storage_deleted', true) + ->where(fn ($query) => $query + ->where('s3_storage_deleted', true) + ->orWhereNull('s3_uploaded') + ->orWhere('s3_uploaded', false)) + ->delete() ?? 0; + + $this->dispatch( + $deletedCount > 0 ? 'success' : 'info', + $deletedCount > 0 ? "Cleaned up {$deletedCount} deleted backup entries." : 'No deleted backup entries found.', + ); + } + + public function deleteBackup(int $executionId, string $password, array $selectedActions = []): bool|string + { + $this->authorize('update', $this->resource); + + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + $execution = $this->backup?->executions()->whereKey($executionId)->first(); + if (! $execution) { + $this->dispatch('error', 'Backup execution not found.'); + + return false; + } + + if ($execution->status === 'running' || $execution->pause_recovery_pending || $execution->s3_cleanup_pending) { + $this->dispatch('error', 'Wait for the backup and recovery operations to finish before deleting it.'); + + return false; + } + + try { + $server = $this->backup->server(); + if (! $execution->local_storage_deleted && filled($execution->filename)) { + if (! $server) { + throw new \RuntimeException('The server is unavailable.'); + } + + deleteBackupsLocally($execution->filename, $server, throwError: true); + } + + if ($this->delete_backup_s3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { + if (! $this->backup->s3) { + throw new \RuntimeException('The S3 storage is unavailable.'); + } + + deleteBackupsS3($execution->filename, $this->backup->s3); + } + + $execution->delete(); + $this->delete_backup_s3 = false; + $this->dispatch('success', 'Backup deleted.'); + + return true; + } catch (Throwable $exception) { + $this->dispatch('error', 'Failed to delete backup: '.$exception->getMessage()); + + return false; + } + } + + public function render() + { + $executions = $this->backup?->executions()->paginate(10); + + return view('livewire.project.shared.storages.volume-backups', [ + 'executions' => $executions ?? collect(), + 'latestExecution' => $this->backup?->executions()->first(), + ]); + } + + private function validateSettings(): bool + { + $this->validate(); + + if (! validate_cron_expression($this->frequency)) { + $this->addError('frequency', 'The frequency must be a valid cron or human expression.'); + + return false; + } + + if ($this->saveToS3 && ! $this->hasValidS3Storage()) { + $this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.'); + + return false; + } + + $this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup; + + return true; + } + + private function persistBackup(bool $enabled): ScheduledVolumeBackup + { + return ScheduledVolumeBackup::query()->updateOrCreate( + ['local_persistent_volume_id' => $this->storage->id], + [ + 'team_id' => currentTeam()->id, + 'frequency' => $this->frequency, + 'enabled' => $enabled, + 'save_s3' => $this->saveToS3, + 'disable_local_backup' => $this->disableLocalBackup, + 'pause_during_backup' => $this->pauseDuringBackup, + 's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null, + 'retention_amount_locally' => $this->retentionAmountLocally, + 'retention_days_locally' => $this->retentionDaysLocally, + 'retention_max_storage_locally' => $this->retentionMaxStorageLocally, + 'retention_amount_s3' => $this->retentionAmountS3, + 'retention_days_s3' => $this->retentionDaysS3, + 'retention_max_storage_s3' => $this->retentionMaxStorageS3, + 'timeout' => $this->timeout, + ], + ); + } + + private function hasValidS3Storage(): bool + { + return $this->s3StorageId !== null + && S3Storage::query() + ->whereKey($this->s3StorageId) + ->where('team_id', currentTeam()->id) + ->where('is_usable', true) + ->exists(); + } +} diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index dd6640c23..914366d4f 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -4,6 +4,7 @@ use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use App\Models\ScheduledVolumeBackup; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -30,7 +31,8 @@ public function mount() return $this->redirectRoute('storage.index', navigate: true); } $this->currentRoute = request()->route()->getName(); - $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count(); + $this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count() + + ScheduledVolumeBackup::where('s3_storage_id', $this->storage->id)->count(); } public function delete() diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index d44c86c0c..3b2864ce2 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Relations\HasMany; use Symfony\Component\Yaml\Yaml; class LocalPersistentVolume extends BaseModel @@ -41,6 +42,11 @@ public function database() return $this->morphTo('resource'); } + public function scheduledBackups(): HasMany + { + return $this->hasMany(ScheduledVolumeBackup::class, 'local_persistent_volume_id'); + } + protected function customizeName($value) { return str($value)->trim()->value; diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 70703cd52..f8b4bdd56 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -69,6 +69,16 @@ protected static function boot(): void 'save_s3' => false, 's3_storage_id' => null, ]); + $volumeBackupIds = ScheduledVolumeBackup::where('s3_storage_id', $storage->id)->pluck('id'); + ScheduledVolumeBackupExecution::whereIn('scheduled_volume_backup_id', $volumeBackupIds) + ->update([ + 's3_storage_deleted' => true, + 's3_cleanup_pending' => false, + ]); + ScheduledVolumeBackup::whereKey($volumeBackupIds)->update([ + 'save_s3' => false, + 's3_storage_id' => null, + ]); }); } @@ -101,6 +111,11 @@ public function scheduledBackups() return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id'); } + public function scheduledVolumeBackups() + { + return $this->hasMany(ScheduledVolumeBackup::class, 's3_storage_id'); + } + public function awsUrl() { return "{$this->endpoint}/{$this->bucket}"; diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php new file mode 100644 index 000000000..23e2369b1 --- /dev/null +++ b/app/Models/ScheduledVolumeBackup.php @@ -0,0 +1,82 @@ + 'boolean', + 'save_s3' => 'boolean', + 'disable_local_backup' => 'boolean', + 'pause_during_backup' => 'boolean', + 'retention_amount_locally' => 'integer', + 'retention_days_locally' => 'integer', + 'retention_max_storage_locally' => 'float', + 'retention_amount_s3' => 'integer', + 'retention_days_s3' => 'integer', + 'retention_max_storage_s3' => 'float', + 'timeout' => 'integer', + ]; + } + + public function volume(): BelongsTo + { + return $this->belongsTo(LocalPersistentVolume::class, 'local_persistent_volume_id'); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function s3(): BelongsTo + { + return $this->belongsTo(S3Storage::class, 's3_storage_id'); + } + + public function executions(): HasMany + { + return $this->hasMany(ScheduledVolumeBackupExecution::class)->latest(); + } + + public function latestExecution(): HasOne + { + return $this->hasOne(ScheduledVolumeBackupExecution::class)->latestOfMany(); + } + + public function server(): ?Server + { + $resource = $this->volume?->resource; + + if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) { + return $resource->service?->server?->fresh(); + } + + return $resource?->destination?->server?->fresh(); + } +} diff --git a/app/Models/ScheduledVolumeBackupExecution.php b/app/Models/ScheduledVolumeBackupExecution.php new file mode 100644 index 000000000..b15294461 --- /dev/null +++ b/app/Models/ScheduledVolumeBackupExecution.php @@ -0,0 +1,43 @@ + 'integer', + 'finished_at' => 'datetime', + 'pause_container_ids' => 'array', + 'pause_recovery_pending' => 'boolean', + 's3_cleanup_pending' => 'boolean', + 'local_storage_deleted' => 'boolean', + 's3_storage_deleted' => 'boolean', + 's3_uploaded' => 'boolean', + ]; + } + + public function scheduledVolumeBackup(): BelongsTo + { + return $this->belongsTo(ScheduledVolumeBackup::class); + } +} diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php index e33e4b919..56bccb06f 100644 --- a/app/View/Components/Forms/Checkbox.php +++ b/app/View/Components/Forms/Checkbox.php @@ -25,6 +25,7 @@ public function __construct( public ?string $helper = null, public string|bool|null $checked = false, public string|bool $instantSave = false, + public bool $live = false, public bool $disabled = false, public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base', public ?string $canGate = null, diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5f0b2e690..4aeda9dbf 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -179,7 +179,7 @@ function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDoc return $database; } -function deleteBackupsLocally(string|array|null $filenames, Server $server): void +function deleteBackupsLocally(string|array|null $filenames, Server $server, bool $throwError = false): void { if (empty($filenames)) { return; @@ -187,8 +187,8 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server): voi if (is_string($filenames)) { $filenames = [$filenames]; } - $quotedFiles = array_map(fn ($file) => "\"$file\"", $filenames); - instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: false); + $quotedFiles = array_map(fn ($file) => escapeshellarg($file), $filenames); + instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: $throwError); $foldersToCheck = collect($filenames)->map(fn ($file) => dirname($file))->unique(); $foldersToCheck->each(fn ($folder) => deleteEmptyBackupFolder($folder, $server)); @@ -214,7 +214,9 @@ function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void 'aws_url' => $s3->awsUrl(), ]); - $disk->delete($filenames); + if (! $disk->delete($filenames)) { + throw new RuntimeException('One or more S3 backup files could not be deleted.'); + } } function deleteEmptyBackupFolder($folderPath, Server $server): void 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 new file mode 100644 index 000000000..6ebf1a80e --- /dev/null +++ b/database/migrations/2026_07_15_102537_create_scheduled_volume_backups_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('local_persistent_volume_id')->unique()->constrained()->restrictOnDelete(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('s3_storage_id')->nullable()->constrained()->nullOnDelete(); + $table->string('frequency'); + $table->boolean('enabled')->default(true); + $table->boolean('save_s3')->default(false); + $table->boolean('disable_local_backup')->default(false); + $table->boolean('pause_during_backup')->default(false); + $table->unsignedInteger('retention_amount_locally')->default(7); + $table->unsignedInteger('retention_amount_s3')->default(7); + $table->unsignedInteger('timeout')->default(3600); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('scheduled_volume_backups'); + } +}; diff --git a/database/migrations/2026_07_15_102538_create_scheduled_volume_backup_executions_table.php b/database/migrations/2026_07_15_102538_create_scheduled_volume_backup_executions_table.php new file mode 100644 index 000000000..20f5fc273 --- /dev/null +++ b/database/migrations/2026_07_15_102538_create_scheduled_volume_backup_executions_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('scheduled_volume_backup_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['success', 'failed', 'running'])->default('running'); + $table->longText('message')->nullable(); + $table->unsignedBigInteger('size')->default(0); + $table->text('filename')->nullable(); + $table->json('pause_container_ids')->nullable(); + $table->boolean('pause_recovery_pending')->default(false); + $table->boolean('s3_cleanup_pending')->default(false); + $table->timestamp('finished_at')->nullable(); + $table->boolean('local_storage_deleted')->default(false); + $table->boolean('s3_storage_deleted')->default(false); + $table->boolean('s3_uploaded')->nullable(); + $table->timestamps(); + + $table->index(['scheduled_volume_backup_id', 'created_at'], 'scheduled_volume_executions_backup_created_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('scheduled_volume_backup_executions'); + } +}; diff --git a/database/migrations/2026_07_15_125721_add_retention_limits_to_scheduled_volume_backups_table.php b/database/migrations/2026_07_15_125721_add_retention_limits_to_scheduled_volume_backups_table.php new file mode 100644 index 000000000..1cf1fb7b1 --- /dev/null +++ b/database/migrations/2026_07_15_125721_add_retention_limits_to_scheduled_volume_backups_table.php @@ -0,0 +1,30 @@ +unsignedInteger('retention_days_locally')->default(0); + $table->decimal('retention_max_storage_locally', 17, 7)->default(0); + $table->unsignedInteger('retention_days_s3')->default(0); + $table->decimal('retention_max_storage_s3', 17, 7)->default(0); + }); + } + + public function down(): void + { + Schema::table('scheduled_volume_backups', function (Blueprint $table) { + $table->dropColumn([ + 'retention_days_locally', + 'retention_max_storage_locally', + 'retention_days_s3', + 'retention_max_storage_s3', + ]); + }); + } +}; diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index 29717b9b8..efd2d9e3b 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -39,7 +39,8 @@ value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif /> @else class([$defaultClass, 'shrink-0']) }} - wire:model={{ $value ?? $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif /> + @if ($live) wire:model.live={{ $value ?? $modelBinding }} @else wire:model={{ $value ?? $modelBinding }} @endif + id="{{ $htmlId }}" @if ($checked) checked @endif /> @endif @endif diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index c81f53ce9..e905aed24 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -10,6 +10,9 @@ @if ($helper) @endif + @isset($labelSuffix) + {{ $labelSuffix }} + @endisset @endif @if ($type === 'password') diff --git a/resources/views/livewire/project/application/backup/create.blade.php b/resources/views/livewire/project/application/backup/create.blade.php new file mode 100644 index 000000000..d2c739b07 --- /dev/null +++ b/resources/views/livewire/project/application/backup/create.blade.php @@ -0,0 +1,36 @@ +
+ @if ($volumes->isEmpty()) +
Add a persistent volume before configuring a backup.
+ @else +
+ + @foreach ($volumes as $volume) + + @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 new file mode 100644 index 000000000..87e473e29 --- /dev/null +++ b/resources/views/livewire/project/application/backup/index.blade.php @@ -0,0 +1,88 @@ +
+ + {{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify + +

Backups

+ + + +
+

Scheduled Backups

+ @can('update', $application) + + + + @endcan +
+ +
+ +
+ + +
diff --git a/resources/views/livewire/project/application/backup/show.blade.php b/resources/views/livewire/project/application/backup/show.blade.php new file mode 100644 index 000000000..9501d473c --- /dev/null +++ b/resources/views/livewire/project/application/backup/show.blade.php @@ -0,0 +1,11 @@ +
+ + {{ data_get_str($application, 'name')->limit(10) }} > Volume Backups | Coolify + +

Volume Backups

+ + + + +
diff --git a/resources/views/livewire/project/application/heading.blade.php b/resources/views/livewire/project/application/heading.blade.php index 443234869..691a90d5e 100644 --- a/resources/views/livewire/project/application/heading.blade.php +++ b/resources/views/livewire/project/application/heading.blade.php @@ -11,6 +11,11 @@ 'route' => 'project.application.deployment.index', 'active' => request()->routeIs('project.application.deployment.index', 'project.application.deployment.show'), ], + [ + 'label' => 'Backups', + 'route' => 'project.application.backup.index', + 'active' => request()->routeIs('project.application.backup.index', 'project.application.backup.show'), + ], [ 'label' => 'Logs', 'route' => 'project.application.logs', @@ -447,6 +452,10 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow- href="{{ route('project.application.deployment.index', $parameters) }}"> Deployments +
- - Logs - - @can('canAccessTerminal') - - Terminal - - @endcan @if ( $database->getMorphClass() === 'App\Models\StandalonePostgresql' || $database->getMorphClass() === 'App\Models\StandaloneMongodb' || @@ -210,6 +200,16 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow- Backups @endif + + Logs + + @can('canAccessTerminal') + + Terminal + + @endcan @if ($database->destination->server->isFunctional()) diff --git a/resources/views/livewire/project/database/scheduled-backups.blade.php b/resources/views/livewire/project/database/scheduled-backups.blade.php index b8241569c..c522cd666 100644 --- a/resources/views/livewire/project/database/scheduled-backups.blade.php +++ b/resources/views/livewire/project/database/scheduled-backups.blade.php @@ -1,4 +1,15 @@ -
+
@if ($database->is_migrated && blank($database->custom_type))
@@ -18,9 +29,16 @@
@else +
+ +
+
+ No scheduled backups match your search. +
@forelse($database->scheduledBackups as $backup) @if ($type == 'database') - $backup->latest_log && @@ -105,7 +123,7 @@ 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'), diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index c7de8ad9e..b8e9e8293 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -12,10 +12,22 @@ $storage->resource_type === 'App\Models\ServiceApplication' || $storage->resource_type === 'App\Models\ServiceDatabase') + helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing."> + + @if ($hasEnabledBackup) + + @endif + + @else + helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing."> + + @if ($hasEnabledBackup) + + @endif + + @endif @if ($isService || $startedAt) @else
- + + + @if ($hasEnabledBackup) + + @endif + +
@@ -51,14 +69,26 @@ @can('update', $resource) @if ($isFirst)
- + + + @if ($hasEnabledBackup) + + @endif + +
@else
- + + + @if ($hasEnabledBackup) + + @endif + +
@@ -74,6 +104,13 @@ Update + @if ($resource instanceof \App\Models\Application) + + + + @endif - + + + @if ($hasEnabledBackup) + + @endif + + @else
- + + + @if ($hasEnabledBackup) + + @endif + +
@@ -101,4 +150,15 @@ @endcan @endif + @if ($isReadOnly && $resource instanceof \App\Models\Application) + @can('update', $resource) +
+ + + +
+ @endcan + @endif
diff --git a/resources/views/livewire/project/shared/storages/volume-backups.blade.php b/resources/views/livewire/project/shared/storages/volume-backups.blade.php new file mode 100644 index 000000000..8549a14c5 --- /dev/null +++ b/resources/views/livewire/project/shared/storages/volume-backups.blade.php @@ -0,0 +1,338 @@ +
+
+
+
+
+

Scheduled Backup

+
+ Save + @if (!$enabled) + Enable Backup + @else + Disable Backup + @endif + + Backup Now + @if ($backup) + + + Delete Backups and Schedule + + + @endif +
+
+

+ Persistent volume: + {{ $storage->name }} +

+
+ +
+ +
+ + @if ($availableS3Storages->isNotEmpty()) + + @elseif ($saveToS3) + + @else + + @endif + @if ($saveToS3) + + @else + + @endif +
+ +
+
+ S3 Storage + @if (!$saveToS3) + (currently disabled) + @else + + @endif +
+ + @if ($availableS3Storages->isEmpty()) + + @else + @foreach ($availableS3Storages as $s3Storage) + + @endforeach + @endif + +
+ +
+

Settings

+
+ Backups made while the application is writing to this volume may be inconsistent or corrupted. You can + pause containers during the archive step for a more consistent backup, but this briefly interrupts the + application. +
+
+ + + +
+ + +

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 ($saveToS3) +
+

S3 Storage Retention

+
+ + + +
+
+ @endif +
+
+
+ + @if ($backup) + @php + $executionCount = method_exists($executions, 'total') ? $executions->total() : $executions->count(); + $currentPage = method_exists($executions, 'currentPage') ? $executions->currentPage() : 1; + $lastPage = method_exists($executions, 'lastPage') ? $executions->lastPage() : 1; + @endphp +
+
+

Executions ({{ $executionCount }})

+ @if ($executionCount > 0) +
+ + + + + + + Page {{ $currentPage }} of {{ $lastPage }} + + + + + + +
+ @endif +
+ + Cleanup Failed Backups + + + + Cleanup Deleted + + +
+
+ +
+ @forelse ($executions as $execution) +
$execution->status === 'running', + 'border-error' => $execution->status === 'failed', + 'border-success' => $execution->status === 'success', + ])> + @if ($execution->status === 'running') +
+ @endif +
+ $execution->status === 'running', + 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $execution->status === 'failed', + 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200' => $execution->status === 'success' && $execution->s3_uploaded === false, + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $execution->status === 'success' && $execution->s3_uploaded !== false, + ])> + @if ($execution->status === 'running') + In Progress + @elseif ($execution->status === 'success' && $execution->s3_uploaded === false) + Success (S3 Warning) + @else + {{ ucfirst($execution->status) }} + @endif + +
+
+ @if ($execution->status === 'running') + + Running for {{ calculateDuration($execution->created_at, now()) }} + + @else + @php + $finishedAt = $execution->finished_at ?? $execution->updated_at; + @endphp + + {{ $finishedAt->diffForHumans() }} + ({{ calculateDuration($execution->created_at, $finishedAt) }}) + • {{ $finishedAt->format('M j, H:i') }} + + @endif + • Volume: {{ $storage->name }} + @if ($execution->size > 0) + • Size: {{ formatBytes($execution->size) }} + @endif +
+
+ Location: {{ $execution->filename ?? 'N/A' }} +
+
+
Backup Availability:
+ !$execution->local_storage_deleted, + 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => $execution->local_storage_deleted, + ])> + + @if (!$execution->local_storage_deleted) + + + + @else + + + + @endif + Local Storage + + + @if ($execution->s3_uploaded !== null) + $execution->s3_uploaded === false && !$execution->s3_storage_deleted, + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $execution->s3_uploaded === true && !$execution->s3_storage_deleted, + 'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => $execution->s3_storage_deleted, + ])> + + @if ($execution->s3_uploaded === true && !$execution->s3_storage_deleted) + + + + @else + + + + @endif + S3 Storage + + + @endif +
+ @if ($execution->message) +
+
{{ $execution->message }}
+
+ @endif + @if ($execution->status !== 'running') +
+ @if ($execution->status === 'success' && !$execution->local_storage_deleted) + + Download + + @endif + @php + $executionCheckboxes = []; + $deleteActions = []; + if (!$execution->local_storage_deleted) { + $deleteActions[] = 'This backup will be permanently deleted from local storage.'; + } + if ($execution->s3_uploaded === true && !$execution->s3_storage_deleted) { + $executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage']; + } + if (empty($deleteActions)) { + $deleteActions[] = 'This backup execution record will be deleted.'; + } + @endphp + + + Delete + + +
+ @endif +
+ @empty +
No executions found.
+ @endforelse +
+
+ @endif +
+ +@script + +@endscript diff --git a/resources/views/livewire/storage/show.blade.php b/resources/views/livewire/storage/show.blade.php index 0d580486e..f510da1ba 100644 --- a/resources/views/livewire/storage/show.blade.php +++ b/resources/views/livewire/storage/show.blade.php @@ -20,7 +20,7 @@ submitAction="delete({{ $storage->id }})" :actions="array_filter([ 'The selected storage location will be permanently deleted from Coolify.', $backupCount > 0 - ? $backupCount . ' backup schedule(s) will be updated to no longer save to S3 and will only store backups locally on the server.' + ? $backupCount . ' backup schedule(s) will stop saving to S3. Existing objects in this storage will not be deleted.' : null, ])" confirmationText="{{ $storage->name }}" confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below" diff --git a/routes/web.php b/routes/web.php index f729af28e..0470ffb99 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,8 @@ use App\Livewire\Notifications\Webhook as NotificationWebhook; use App\Livewire\Profile\Appearance as ProfileAppearance; use App\Livewire\Profile\Index as ProfileIndex; +use App\Livewire\Project\Application\Backup\Index as ApplicationBackupIndex; +use App\Livewire\Project\Application\Backup\Show as ApplicationBackupShow; use App\Livewire\Project\Application\Configuration as ApplicationConfiguration; use App\Livewire\Project\Application\Deployment\Index as DeploymentIndex; use App\Livewire\Project\Application\Deployment\Show as DeploymentShow; @@ -91,6 +93,7 @@ use App\Livewire\Team\Member\Index as TeamMemberIndex; use App\Livewire\Terminal\Index as TerminalIndex; use App\Models\ScheduledDatabaseBackupExecution; +use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\ServiceDatabase; use App\Providers\RouteServiceProvider; @@ -224,6 +227,8 @@ Route::get('/advanced', ApplicationConfiguration::class)->name('project.application.advanced'); Route::get('/environment-variables', ApplicationConfiguration::class)->name('project.application.environment-variables'); Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage'); + Route::get('/backups', ApplicationBackupIndex::class)->name('project.application.backup.index'); + Route::get('/backups/{backup_uuid}', ApplicationBackupShow::class)->name('project.application.backup.show'); Route::get('/source', ApplicationConfiguration::class)->name('project.application.source'); Route::get('/servers', ApplicationConfiguration::class)->name('project.application.servers'); Route::get('/scheduled-tasks', ApplicationConfiguration::class)->name('project.application.scheduled-tasks.show'); @@ -411,6 +416,68 @@ } })->name('download.backup'); + Route::get('/download/volume-backup/{executionId}', function () { + try { + $user = auth()->user(); + $team = $user->currentTeam(); + if (is_null($team)) { + return response()->json(['message' => 'Team not found.'], 404); + } + if ($user->isAdminFromSession() === false) { + return response()->json(['message' => 'Only team admins/owners can download backups.'], 403); + } + + $execution = ScheduledVolumeBackupExecution::query() + ->with('scheduledVolumeBackup.volume.resource') + ->findOrFail(request()->route('executionId')); + if ($team->id !== 0 && $team->id !== $execution->scheduledVolumeBackup->team_id) { + return response()->json(['message' => 'Permission denied.'], 403); + } + if ($execution->local_storage_deleted || blank($execution->filename)) { + return response()->json(['message' => 'Backup not found locally on the server.'], 404); + } + + $server = $execution->scheduledVolumeBackup->server(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $filename = $execution->filename; + $disk = Storage::build([ + 'driver' => 'sftp', + 'host' => $server->ip, + 'port' => (int) $server->port, + 'username' => $server->user, + 'privateKey' => $server->privateKey->getKeyLocation(), + 'root' => '/', + ]); + if (! $disk->exists($filename)) { + return response()->json(['message' => 'Backup not found locally on the server.'], 404); + } + + return new StreamedResponse(function () use ($disk, $filename) { + if (ob_get_level()) { + ob_end_clean(); + } + $stream = $disk->readStream($filename); + if ($stream === false || is_null($stream)) { + abort(500, 'Failed to open stream for the requested file.'); + } + while (! feof($stream)) { + echo fread($stream, 2048); + flush(); + } + + fclose($stream); + }, 200, [ + 'Content-Type' => 'application/gzip', + 'Content-Disposition' => 'attachment; filename="'.basename($filename).'"', + ]); + } catch (Throwable) { + return response()->json(['message' => 'Failed to download backup.'], 500); + } + })->name('download.volume-backup'); + }); Route::any('/{any}', function () { diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index fe396b5da..b0041e226 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -72,6 +72,35 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name session(['currentTeam' => $this->team]); }); +it('renders a highlighted enable backup button and a regular disable backup button', function () { + $view = file_get_contents(resource_path('views/livewire/project/database/backup-edit.blade.php')); + + expect($view) + ->toContain('wire:target="toggleEnabled" isHighlighted>Enable Backup') + ->toContain('wire:target="toggleEnabled">Disable Backup') + ->not->toContain('label="Backup Enabled"'); +}); + +it('enables and disables a scheduled database backup from the title action', function () { + $backup = createBackupForEditValidationTest($this->team, [ + 'enabled' => false, + 'save_s3' => false, + ]); + + $component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) + ->assertSet('backupEnabled', false) + ->assertSee('Enable Backup') + ->call('toggleEnabled') + ->assertSet('backupEnabled', true) + ->assertSee('Disable Backup'); + + expect($backup->refresh()->enabled)->toBeTruthy(); + + $component->call('toggleEnabled')->assertSet('backupEnabled', false); + + expect($backup->refresh()->enabled)->toBeFalsy(); +}); + it('disables S3 backup when saved without a selected S3 storage', function () { $backup = createBackupForEditValidationTest($this->team); @@ -177,6 +206,30 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name ->assertSee('No S3 storage available'); }); +it('allows S3 backups to be disabled when no usable storage remains', function () { + $backup = createBackupForEditValidationTest($this->team, [ + 'save_s3' => true, + 's3_storage_id' => null, + ]); + + $component = Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => collect(), + ])->assertSet('saveS3', true); + + preg_match('/]*wire:model=(?:"saveS3"|saveS3))[^>]*>/', $component->html(), $matches); + expect($matches[0] ?? null)->not->toBeNull() + ->and(preg_match('/\sdisabled(?:\s|\/>)/', $matches[0]))->toBe(0); + + $component->set('saveS3', false)->call('instantSave')->assertDispatched('success'); + + expect($backup->refresh()->save_s3)->toBeFalsy() + ->and($backup->s3_storage_id)->toBeNull(); + + preg_match('/]*wire:model=(?:"saveS3"|saveS3))[^>]*>/', $component->html(), $matches); + expect(preg_match('/\sdisabled(?:\s|\/>)/', $matches[0]))->toBe(1); +}); + it('shows when S3 backups are currently disabled', function () { createS3StorageForBackupEditValidationTest($this->team); $backup = createBackupForEditValidationTest($this->team, [ diff --git a/tests/Feature/BackupSearchTest.php b/tests/Feature/BackupSearchTest.php new file mode 100644 index 000000000..c6e91abb3 --- /dev/null +++ b/tests/Feature/BackupSearchTest.php @@ -0,0 +1,121 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('renders frontend-only application backup search data for volume names and frequencies', function () { + $application = createBackupSearchApplication($this->team); + $dailyVolume = createBackupSearchVolume($application, 'app-data'); + $weeklyVolume = createBackupSearchVolume($application, 'Cache-Data'); + + ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $dailyVolume->id, + 'team_id' => $this->team->id, + 'frequency' => 'daily', + ]); + ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $weeklyVolume->id, + 'team_id' => $this->team->id, + 'frequency' => '0 4 * * 0', + ]); + + $parameters = [ + 'project_uuid' => $application->project()->uuid, + 'environment_uuid' => $application->environment->uuid, + 'application_uuid' => $application->uuid, + ]; + + $this->get(route('project.application.backup.index', [...$parameters, 'search' => 'cache'])) + ->assertOk() + ->assertSee('Cache-Data') + ->assertSee('Volume: app-data') + ->assertSee('x-model="search"', false) + ->assertSee('x-show=', false) + ->assertSee('No scheduled backups match your search.'); +}); + +it('renders frontend-only database backup search data for database names and frequencies', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandalonePostgresql::create([ + 'name' => 'Orders-Primary', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $database->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + ]); + $database->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 'frequency' => '0 3 * * 1', + ]); + + $parameters = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'database_uuid' => $database->uuid, + ]; + + $this->get(route('project.database.backup.index', [...$parameters, 'search' => '0 3'])) + ->assertOk() + ->assertSee('

0 3 * * 1

', false) + ->assertSee('

daily

', false) + ->assertSee('x-model="search"', false) + ->assertSee('x-show=', false) + ->assertSee('No scheduled backups match your search.'); +}); + +function createBackupSearchApplication(Team $team): Application +{ + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + return $application; +} + +function createBackupSearchVolume(Application $application, string $name): LocalPersistentVolume +{ + return LocalPersistentVolume::create([ + 'name' => $name, + 'mount_path' => '/'.str($name)->lower(), + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); +} diff --git a/tests/Feature/MobileResourceMenuTest.php b/tests/Feature/MobileResourceMenuTest.php index 7f4d6fe9d..47ef03510 100644 --- a/tests/Feature/MobileResourceMenuTest.php +++ b/tests/Feature/MobileResourceMenuTest.php @@ -131,6 +131,19 @@ function mobileActionsAreBeforeSelect(string $heading, string $actionsId, string return $actionsPosition !== false && $selectPosition !== false && $actionsPosition < $selectPosition; } +it('places database backups immediately after configuration in navigation menus', function () { + $databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php')); + $mobileMenuItems = str($databaseHeading)->between('$databasePageItems = [', '$databaseConfigurationItems = [')->toString(); + $desktopMenuItems = str($databaseHeading)->between('class="scrollbar hidden', '')->toString(); + + foreach ([$mobileMenuItems, $desktopMenuItems] as $menuItems) { + expect(strpos($menuItems, 'Configuration')) + ->toBeLessThan(strpos($menuItems, 'Backups')) + ->and(strpos($menuItems, 'Backups'))->toBeLessThan(strpos($menuItems, 'Logs')) + ->and(strpos($menuItems, 'Logs'))->toBeLessThan(strpos($menuItems, 'Terminal')); + } +}); + it('keeps configuration sidebars hidden until desktop breakpoint', function () { expect(file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'))) ->toContain('sub-menu-wrapper hidden md:flex'); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php new file mode 100644 index 000000000..94bde7b32 --- /dev/null +++ b/tests/Feature/VolumeBackupTest.php @@ -0,0 +1,1092 @@ +toBeTrue() + ->and(class_exists(ScheduledVolumeBackupExecution::class))->toBeTrue() + ->and(class_exists(VolumeBackupJob::class))->toBeTrue() + ->and(class_exists(VolumeBackups::class))->toBeTrue() + ->and(method_exists(LocalPersistentVolume::class, 'scheduledBackups'))->toBeTrue(); +}); + +it('provides application backup index and detail routes', function () { + expect(Route::has('project.application.backup.index'))->toBeTrue() + ->and(Route::has('project.application.backup.show'))->toBeTrue() + ->and(Route::has('download.volume-backup'))->toBeTrue(); +}); + +it('creates a scheduled backup with a preselected volume from the shared modal', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + + Livewire::test(CreateScheduledVolumeBackup::class, [ + 'application' => $application, + 'selectedVolumeId' => $volume->id, + ]) + ->assertSet('volumeId', $volume->id) + ->assertSee($volume->name) + ->set('frequency', 'daily') + ->call('submit') + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + + expect($backup->local_persistent_volume_id)->toBe($volume->id) + ->and($backup->frequency)->toBe('daily') + ->and($backup->enabled)->toBeTrue(); +}); + +it('shows volume backups on the application backups pages', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + $parameters = [ + 'project_uuid' => $application->project()->uuid, + 'environment_uuid' => $application->environment->uuid, + 'application_uuid' => $application->uuid, + ]; + + $this->get(route('project.application.backup.index', $parameters)) + ->assertOk() + ->assertSee('Scheduled Backups') + ->assertSee($volume->name); + + $this->get(route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid])) + ->assertOk() + ->assertSee('

Volume Backups

', false) + ->assertSee($volume->name); +}); + +it('shows the configure backup modal trigger instead of inline backup settings on a volume', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + + Livewire::test(Show::class, [ + 'storage' => $volume, + 'resource' => $application, + ]) + ->assertSee('Configure Backup') + ->assertDontSee('Backups made while the application is writing'); +}); + +it('only shows the backup enabled badge for an enabled volume backup', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'enabled' => false, + ]); + + $component = Livewire::test(Show::class, [ + 'storage' => $volume, + 'resource' => $application, + ])->assertDontSee('Backup enabled'); + + $backup->update(['enabled' => true]); + + $component + ->dispatch('refreshVolumeBackups') + ->assertSeeInOrder(['Volume Name', 'Backup enabled']); + + Livewire::test(Show::class, [ + 'storage' => $volume, + 'resource' => $application, + 'isFirst' => false, + ])->assertSeeInOrder(['Volume Name', 'Backup enabled']); +}); + +it('stores volume backup schedules and executions', function () { + expect(Schema::hasColumns('scheduled_volume_backups', [ + 'uuid', + 'local_persistent_volume_id', + 'team_id', + 's3_storage_id', + 'frequency', + 'enabled', + 'save_s3', + 'disable_local_backup', + 'pause_during_backup', + 'retention_amount_locally', + 'retention_days_locally', + 'retention_max_storage_locally', + 'retention_amount_s3', + 'retention_days_s3', + 'retention_max_storage_s3', + 'timeout', + ]))->toBeTrue() + ->and(Schema::hasColumns('scheduled_volume_backup_executions', [ + 'uuid', + 'scheduled_volume_backup_id', + 'status', + 'message', + 'size', + 'filename', + 'pause_container_ids', + 'pause_recovery_pending', + 's3_cleanup_pending', + 'finished_at', + 'local_storage_deleted', + 's3_storage_deleted', + 's3_uploaded', + ]))->toBeTrue(); +}); + +it('exposes actions to manage and run volume backups', function () { + expect(method_exists(VolumeBackups::class, 'save'))->toBeTrue() + ->and(method_exists(VolumeBackups::class, 'backupNow'))->toBeTrue() + ->and(method_exists(VolumeBackups::class, 'toggleEnabled'))->toBeTrue() + ->and(method_exists(VolumeBackups::class, 'delete'))->toBeTrue() + ->and(method_exists(VolumeBackups::class, 'cleanupFailed'))->toBeTrue() + ->and(method_exists(VolumeBackups::class, 'cleanupDeleted'))->toBeTrue() + ->and(method_exists(VolumeBackups::class, 'deleteBackup'))->toBeTrue(); +}); + +it('renders volume backup executions like database backup executions', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume, $server] = createVolumeBackupApplication($team); + $server->settings->update(['server_timezone' => 'Europe/Budapest']); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', + 'size' => 1024, + 'finished_at' => now(), + ]); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->assertSet('timezone', 'Europe/Budapest') + ->assertSeeInOrder([ + 'Scheduled Backup', + 'Save', + 'Disable Backup', + 'Backup Now', + 'Delete Backups and Schedule', + 'Persistent volume:', + 'Pause containers while creating the archive', + 'S3 Enabled', + 'Disable Local Backup', + 'S3 Storage', + 'Settings', + 'Frequency', + 'Timezone', + 'Timeout', + 'Backup Retention Settings', + 'Local Backup Retention', + 'Executions', + ]) + ->assertSee('Persistent volume:') + ->assertSee('app-data') + ->assertSee('Scheduled Backup') + ->assertSee('Save') + ->assertSee('Disable Backup') + ->assertSee('Backup Now') + ->assertSee('Delete Backups and Schedule') + ->assertSee('S3 Enabled') + ->assertSee('Disable Local Backup') + ->assertSee('S3 Storage') + ->assertSee('(currently disabled)') + ->assertSee('Timezone') + ->assertSee('Setting a value to 0 means unlimited retention.') + ->assertSee('Days to keep backups') + ->assertSee('Maximum storage (GB)') + ->assertSee('Executions') + ->assertSee('Page 1 of 1') + ->assertSee('Cleanup Failed Backups') + ->assertSee('Cleanup Deleted') + ->assertSee('Backup Availability:') + ->assertSee('Local Storage') + ->assertSee('Location: /data/coolify/backups/volumes/test/archive.tar.gz') + ->assertSee('Download') + ->assertSee('Delete') + ->assertDontSee('border border-neutral-200', false); +}); + +it('cleans up failed and fully deleted volume backup execution records', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + $failed = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'failed', + ]); + $deleted = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'local_storage_deleted' => true, + 's3_storage_deleted' => true, + 's3_uploaded' => true, + ]); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->call('cleanupFailed') + ->assertDispatched('success') + ->call('cleanupDeleted') + ->assertDispatched('success'); + + expect($failed->fresh())->toBeNull() + ->and($deleted->fresh())->toBeNull(); +}); + +it('deletes an individual volume backup archive and execution', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + Process::fake(); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', + 'finished_at' => now(), + ]); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->call('deleteBackup', $execution->id, 'password') + ->assertDispatched('success'); + + expect($execution->fresh())->toBeNull(); + Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') + && str_contains($process->command, 'archive.tar.gz')); +}); + +it('prevents another team from downloading a volume backup', function () { + $backupTeam = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($backupTeam); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $backupTeam->id, + 'frequency' => 'daily', + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', + ]); + $otherTeam = Team::factory()->create(); + signInForVolumeBackups($this, $otherTeam); + + $this->get(route('download.volume-backup', $execution->id))->assertForbidden(); +}); + +it('enables and disables volume backups from the title action', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + + $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->assertSet('enabled', false) + ->assertSee('Enable Backup') + ->call('toggleEnabled') + ->assertSet('enabled', true) + ->assertSee('Disable Backup'); + + expect(ScheduledVolumeBackup::query()->sole()->enabled)->toBeTrue(); + + $component->call('toggleEnabled')->assertSet('enabled', false); + + expect(ScheduledVolumeBackup::query()->sole()->enabled)->toBeFalse(); +}); + +function createVolumeBackupApplication(Team $team): array +{ + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'ip' => '203.0.113.10', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + return [$application, $volume, $server]; +} + +function signInForVolumeBackups($testCase, Team $team): User +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $testCase->actingAs($user); + session(['currentTeam' => $team]); + + return $user; +} + +it('creates a local scheduled backup for a persistent volume', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->assertSee('Scheduled Backup') + ->assertSee('Persistent volume:') + ->assertSee('inconsistent or corrupted') + ->set('frequency', 'daily') + ->set('retentionAmountLocally', 5) + ->set('retentionDaysLocally', 14) + ->set('retentionMaxStorageLocally', 1.5) + ->set('pauseDuringBackup', true) + ->call('save') + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + + expect($backup->local_persistent_volume_id)->toBe($volume->id) + ->and($backup->team_id)->toBe($team->id) + ->and($backup->frequency)->toBe('daily') + ->and($backup->retention_amount_locally)->toBe(5) + ->and($backup->retention_days_locally)->toBe(14) + ->and($backup->retention_max_storage_locally)->toBe(1.5) + ->and($backup->pause_during_backup)->toBeTrue() + ->and($backup->save_s3)->toBeFalse(); +}); + +it('only accepts a usable S3 storage owned by the current team', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $foreignStorage = S3Storage::create([ + 'name' => 'Foreign S3', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => Team::factory()->create()->id, + 'is_usable' => true, + ]); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->set('frequency', 'daily') + ->set('saveToS3', true) + ->set('s3StorageId', $foreignStorage->id) + ->call('save') + ->assertHasErrors('s3StorageId'); + + expect(ScheduledVolumeBackup::query()->count())->toBe(0); +}); + +it('saves the database-style S3 backup controls immediately', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + + $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->assertSet('s3StorageId', $s3Storage->id) + ->set('saveToS3', true) + ->call('instantSave') + ->set('disableLocalBackup', true) + ->set('retentionAmountS3', 10) + ->set('retentionDaysS3', 30) + ->set('retentionMaxStorageS3', 5.5) + ->call('instantSave'); + + $backup = ScheduledVolumeBackup::query()->sole(); + expect($backup->save_s3)->toBeTrue() + ->and($backup->s3_storage_id)->toBe($s3Storage->id) + ->and($backup->disable_local_backup)->toBeTrue() + ->and($backup->retention_amount_s3)->toBe(10) + ->and($backup->retention_days_s3)->toBe(30) + ->and($backup->retention_max_storage_s3)->toBe(5.5); + + $component->set('saveToS3', false)->call('instantSave'); + + expect($backup->fresh()->save_s3)->toBeFalse() + ->and($backup->fresh()->s3_storage_id)->toBeNull() + ->and($backup->fresh()->disable_local_backup)->toBeFalse(); +}); + +it('allows volume S3 backups to be disabled when no usable storage remains', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => null, + ]); + + $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->assertSet('saveToS3', true); + + preg_match('/]*wire:model=(?:"saveToS3"|saveToS3))[^>]*>/', $component->html(), $matches); + expect($matches[0] ?? null)->not->toBeNull() + ->and(preg_match('/\sdisabled(?:\s|\/>)/', $matches[0]))->toBe(0); + + $component->set('saveToS3', false)->call('instantSave')->assertDispatched('success'); + + expect($backup->refresh()->save_s3)->toBeFalse() + ->and($backup->s3_storage_id)->toBeNull(); + + preg_match('/]*wire:model=(?:"saveToS3"|saveToS3))[^>]*>/', $component->html(), $matches); + expect(preg_match('/\sdisabled(?:\s|\/>)/', $matches[0]))->toBe(1); +}); + +it('disables S3 volume backups when the storage is deleted', function () { + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 's3_storage_id' => $s3Storage->id, + 'frequency' => 'daily', + 'save_s3' => true, + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/s3.tar.gz', + 's3_uploaded' => true, + 's3_cleanup_pending' => true, + ]); + + $s3Storage->delete(); + + expect($backup->fresh()->save_s3)->toBeFalse() + ->and($backup->fresh()->s3_storage_id)->toBeNull() + ->and($execution->fresh()->s3_storage_deleted)->toBeTrue() + ->and($execution->fresh()->s3_cleanup_pending)->toBeFalse(); +}); + +it('queues a manual backup before a schedule has been saved', function () { + Queue::fake(); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->call('backupNow') + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + + expect($backup->enabled)->toBeFalse(); + Queue::assertPushed(VolumeBackupJob::class, fn (VolumeBackupJob $job) => $job->backup->is($backup)); +}); + +it('deletes local archives before deleting a volume backup schedule', function () { + Process::fake(); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz', + 'size' => 128, + ]); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->call('delete', 'password') + ->assertDispatched('success'); + + expect(ScheduledVolumeBackup::query()->count())->toBe(0); + Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') + && str_contains($process->command, 'archive.tar.gz')); +}); + +it('refuses to delete a schedule while its backup is running', function () { + Process::fake(); + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'running', + ]); + + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->call('delete', 'password') + ->assertDispatched('error'); + + expect($backup->fresh())->not->toBeNull(); + Process::assertNothingRan(); +}); + +it('uses the backup operation lock while deleting a schedule', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + $lock = Cache::lock(VolumeBackupJob::lockKey($backup->id), 60); + $lock->get(); + + try { + Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]) + ->call('delete', 'password') + ->assertDispatched('error'); + + expect($backup->fresh())->not->toBeNull(); + } finally { + $lock->release(); + } +}); + +it('prevents a volume with tracked backup archives from being deleted', function () { + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + + expect(fn () => $volume->delete())->toThrow(QueryException::class); + expect($volume->fresh())->not->toBeNull(); +}); + +it('marks a running execution failed even when the job instance lost its execution state', function () { + Process::fake(); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'running', + 'filename' => '/data/coolify/backups/volumes/test/timed-out.tar.gz', + ]); + + (new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out')); + + expect($execution->fresh()->status)->toBe('failed') + ->and($execution->fresh()->message)->toBe('Worker timed out') + ->and($execution->fresh()->finished_at)->not->toBeNull() + ->and($execution->fresh()->filename)->toBeNull(); + Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') + && str_contains($process->command, 'timed-out.tar.gz')); +}); + +it('archives a named volume on its server', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'enabled' => true, + 'retention_amount_locally' => 7, + 'retention_amount_s3' => 7, + 'timeout' => 3600, + ]); + + Process::fake([ + '*du -b*' => '128', + '*' => '', + ]); + + $job = new VolumeBackupJob($backup); + $middleware = $job->middleware(); + $job->handle(); + + $execution = ScheduledVolumeBackupExecution::query()->sole(); + + expect($middleware)->toHaveCount(1) + ->and($middleware[0])->toBeInstanceOf(WithoutOverlapping::class) + ->and($job->queue)->toBe(crons_queue()) + ->and($execution->status)->toBe('success') + ->and($execution->size)->toBe(128) + ->and($execution->filename)->toEndWith('.tar.gz'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume inspect') + && str_contains($process->command, 'docker run --rm --name ') + && str_contains($process->command, 'app-data:/volume:ro') + && str_contains($process->command, 'tar -czf -') + && str_contains($process->command, '> ') + && str_contains($process->command, '.tar.gz') + && ! str_contains($process->command, ':/backup')); +}); + +it('removes local volume backups older than the configured retention days', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'retention_amount_locally' => 0, + 'retention_days_locally' => 7, + 'retention_max_storage_locally' => 0, + ]); + $expiredExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/expired.tar.gz', + 'size' => 64, + ]); + ScheduledVolumeBackupExecution::query()->whereKey($expiredExecution)->update(['created_at' => now()->subDays(8)]); + $recentExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/recent.tar.gz', + 'size' => 64, + ]); + ScheduledVolumeBackupExecution::query()->whereKey($recentExecution)->update(['created_at' => now()->subDay()]); + + Process::fake([ + '*du -b*' => '128', + '*' => '', + ]); + + (new VolumeBackupJob($backup))->handle(); + + expect($expiredExecution->fresh())->toBeNull() + ->and($recentExecution->fresh())->not->toBeNull(); + Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') + && str_contains($process->command, 'expired.tar.gz')); +}); + +it('removes oldest local volume backups over the configured storage limit', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'retention_amount_locally' => 0, + 'retention_days_locally' => 0, + 'retention_max_storage_locally' => 0.00000015, + ]); + $oldExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/over-limit.tar.gz', + 'size' => 64, + ]); + ScheduledVolumeBackupExecution::query()->whereKey($oldExecution)->update(['created_at' => now()->subDay()]); + + Process::fake([ + '*du -b*' => '128', + '*' => '', + ]); + + (new VolumeBackupJob($backup))->handle(); + + expect($oldExecution->fresh())->toBeNull() + ->and($backup->executions()->where('status', 'success')->count())->toBe(1); + Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') + && str_contains($process->command, 'over-limit.tar.gz')); +}); + +it('keeps a successful backup successful when retention cleanup fails', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'disable_local_backup' => true, + 'retention_amount_locally' => 1, + ]); + $oldExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/test/old.tar.gz', + 'size' => 64, + 'created_at' => now()->subDay(), + ]); + + Process::fake([ + '*rm -f*' => Process::result(errorOutput: 'permission denied', exitCode: 1), + '*du -b*' => '128', + '*' => '', + ]); + + (new VolumeBackupJob($backup))->handle(); + + $latestExecution = $backup->executions()->first(); + + expect($latestExecution->status)->toBe('success') + ->and($latestExecution->filename)->not->toBeNull() + ->and($oldExecution->fresh()->local_storage_deleted)->toBeFalse(); +}); + +it('pauses and resumes containers that use the volume when requested', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'pause_during_backup' => true, + ]); + + Process::fake([ + '*docker ps -q*' => "abc123\ndef456", + '*du -b*' => '128', + '*' => '', + ]); + + (new VolumeBackupJob($backup))->handle(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'trap cleanup EXIT HUP INT TERM') + && str_contains($process->command, 'docker pause') + && str_contains($process->command, 'docker unpause') + && str_contains($process->command, 'state_file=')); + Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}') + && str_contains($process->command, "grep -Fqx -- 'app-data'") + && str_contains($process->command, '{{.State.Paused}}') + && str_contains($process->command, 'continue')); +}); + +it('finds containers using a bind mounted host path before pausing', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $volume->update(['host_path' => '/srv/app-data']); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'pause_during_backup' => true, + ]); + + Process::fake([ + '*docker ps -q*' => 'abc123', + '*du -b*' => '128', + '*' => '', + ]); + + (new VolumeBackupJob($backup))->handle(); + + Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}') + && str_contains($process->command, "grep -Fqx -- '/srv/app-data'")); +}); + +it('retries container recovery when a timed out backup left a container paused', function () { + Queue::fake(); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'running', + 'pause_recovery_pending' => true, + ]); + Process::fake([ + '*cat *coolify-volume-backup-*' => 'abc123', + '*docker unpause*' => Process::result(errorOutput: 'daemon unavailable', exitCode: 1), + ]); + + (new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out')); + + expect($execution->fresh()->status)->toBe('failed') + ->and($execution->fresh()->message)->toContain('Container recovery failed') + ->and($execution->fresh()->pause_container_ids)->toBe(['abc123']) + ->and($execution->fresh()->pause_recovery_pending)->toBeTrue(); + Queue::assertPushed(VolumeBackupRecoveryJob::class); +}); + +it('resumes containers and removes a partial archive when archiving fails', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => 'daily', + 'pause_during_backup' => true, + ]); + Process::fake([ + '*docker ps -q*' => 'abc123', + '*trap cleanup EXIT HUP INT TERM*' => Process::result(errorOutput: 'archive failed', exitCode: 1), + '*' => '', + ]); + + expect(fn () => (new VolumeBackupJob($backup))->handle())->toThrow(RuntimeException::class); + + $execution = ScheduledVolumeBackupExecution::query()->sole(); + expect($execution->status)->toBe('failed') + ->and($execution->pause_container_ids)->toBeNull(); + Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') + && str_contains($process->command, '.tar.gz')); +}); + +it('throws when the S3 filesystem reports that deletion failed', function () { + $disk = Mockery::mock(); + $disk->shouldReceive('delete')->once()->andReturnFalse(); + Storage::shouldReceive('build')->once()->andReturn($disk); + $s3 = new S3Storage([ + 'key' => 'key', + 'secret' => 'secret', + 'region' => 'us-east-1', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + ]); + + expect(fn () => deleteBackupsS3('archive.tar.gz', $s3)) + ->toThrow(RuntimeException::class, 'could not be deleted'); +}); + +it('cleans an interrupted S3 upload and coordinates recovery with the backup lock', function () { + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 's3_storage_id' => $s3Storage->id, + 'frequency' => 'daily', + 'save_s3' => true, + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'failed', + 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', + 's3_cleanup_pending' => true, + ]); + $disk = Mockery::mock(); + $disk->shouldReceive('delete')->once()->andReturnTrue(); + Storage::shouldReceive('build')->once()->andReturn($disk); + $job = new VolumeBackupRecoveryJob($execution); + + expect($job->middleware()[0]->getLockKey($job))->toBe(VolumeBackupJob::lockKey($backup->id)); + $job->handle(); + + expect($execution->fresh()->s3_cleanup_pending)->toBeFalse() + ->and($execution->fresh()->s3_storage_deleted)->toBeTrue(); +}); + +it('keeps the S3 key tracked when interrupted upload cleanup must be retried', function () { + Queue::fake(); + Process::fake(); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 's3_storage_id' => $s3Storage->id, + 'frequency' => 'daily', + 'save_s3' => true, + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'running', + 'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz', + 's3_cleanup_pending' => true, + ]); + $disk = Mockery::mock(); + $disk->shouldReceive('delete')->once()->andReturnFalse(); + Storage::shouldReceive('build')->once()->andReturn($disk); + + (new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out')); + + expect($execution->fresh()->filename)->toBe('/data/coolify/backups/volumes/test/interrupted.tar.gz') + ->and($execution->fresh()->s3_cleanup_pending)->toBeTrue(); + Queue::assertPushed(VolumeBackupRecoveryJob::class); +}); + +it('dispatches due scheduled volume backups', function () { + config(['constants.coolify.self_hosted' => true]); + Carbon::setTestNow('2026-07-15 12:00:00'); + Queue::fake(); + $team = Team::factory()->create(); + [$application, $volume, $server] = createVolumeBackupApplication($team); + $server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + expect($server->fresh()->isFunctional())->toBeTrue(); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => '* * * * *', + 'enabled' => true, + ]); + Cache::forget("scheduled-volume-backup:{$backup->id}"); + expect($backup->server()?->isFunctional())->toBeTrue(); + + (new ScheduledJobManager)->handle(); + + Queue::assertPushed( + VolumeBackupJob::class, + fn (VolumeBackupJob $job) => $job->backup->is($backup), + ); +}); + +it('dispatches pending recovery without starting another volume backup', function () { + config(['constants.coolify.self_hosted' => true]); + Queue::fake(); + $team = Team::factory()->create(); + [$application, $volume, $server] = createVolumeBackupApplication($team); + $server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 'frequency' => '* * * * *', + 'enabled' => true, + ]); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'failed', + 'pause_recovery_pending' => true, + ]); + + (new ScheduledJobManager)->handle(); + + Queue::assertPushed( + VolumeBackupRecoveryJob::class, + fn (VolumeBackupRecoveryJob $job) => $job->execution->is($execution), + ); + Queue::assertNotPushed(VolumeBackupJob::class); +}); From 4eec1ac54741da73716c239a4a954b385b25f735 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:59:35 +0200 Subject: [PATCH 02/17] fix(backups): stop containers during volume backup creation Track and recover stopped containers across interrupted backup executions. --- app/Jobs/ScheduledJobManager.php | 8 +- app/Jobs/VolumeBackupJob.php | 23 +++-- app/Jobs/VolumeBackupRecoveryJob.php | 14 +-- .../Project/Shared/Storages/VolumeBackups.php | 14 +-- app/Models/ScheduledVolumeBackup.php | 4 +- app/Models/ScheduledVolumeBackupExecution.php | 8 +- ..._create_scheduled_volume_backups_table.php | 2 +- ...heduled_volume_backup_executions_table.php | 4 +- .../project/shared/storages/show.blade.php | 20 ++-- .../shared/storages/volume-backups.blade.php | 16 +-- tests/Feature/VolumeBackupTest.php | 99 ++++++++++++++----- 11 files changed, 131 insertions(+), 81 deletions(-) diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 92a0db748..7d7f285c9 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -112,7 +112,7 @@ public function handle(): void } try { - $this->recoverPausedVolumeBackupContainers(); + $this->recoverStoppedVolumeBackupContainers(); $this->processScheduledVolumeBackups(); } catch (\Exception $e) { Log::channel('scheduled-errors')->error('Failed to process scheduled volume backups', [ @@ -365,11 +365,11 @@ private function processScheduledVolumeBackups(): void }); } - private function recoverPausedVolumeBackupContainers(): void + private function recoverStoppedVolumeBackupContainers(): void { ScheduledVolumeBackupExecution::query() ->where(fn (Builder $query) => $query - ->where('pause_recovery_pending', true) + ->where('stop_recovery_pending', true) ->orWhere('s3_cleanup_pending', true)) ->chunkById(self::CHUNK_SIZE, function ($executions): void { foreach ($executions as $execution) { @@ -385,7 +385,7 @@ private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): vo if ($backup->executions() ->where(fn (Builder $query) => $query - ->where('pause_recovery_pending', true) + ->where('stop_recovery_pending', true) ->orWhere('s3_cleanup_pending', true)) ->exists()) { $this->skippedCount++; diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index fe1668001..99d2cd618 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -83,11 +83,11 @@ public function handle(): void .' '.escapeshellarg($image) .' tar -czf - -C /volume . > '.escapeshellarg($backupLocation); - if ($this->backup->pause_during_backup) { + if ($this->backup->stop_during_backup) { $containers = $this->containersUsingVolume($source, $server); if ($containers !== []) { - $this->execution->update(['pause_recovery_pending' => true]); - $archiveCommand = $this->archiveWithPausedContainers( + $this->execution->update(['stop_recovery_pending' => true]); + $archiveCommand = $this->archiveWithStoppedContainers( $archiveCommand, $containers, VolumeBackupRecoveryJob::stateFile($this->execution), @@ -101,8 +101,8 @@ public function handle(): void $archiveCommand, ], $server, timeout: $this->timeout, disableMultiplexing: true); $this->execution->update([ - 'pause_container_ids' => null, - 'pause_recovery_pending' => false, + 'stop_container_ids' => null, + 'stop_recovery_pending' => false, ]); $size = (int) instant_remote_process( @@ -253,7 +253,7 @@ private function containersUsingVolume(string $source, Server $server): array /** * @param array $containers */ - private function archiveWithPausedContainers(string $archiveCommand, array $containers, string $stateFile): string + private function archiveWithStoppedContainers(string $archiveCommand, array $containers, string $stateFile): string { if ($containers === []) { return $archiveCommand; @@ -263,9 +263,12 @@ private function archiveWithPausedContainers(string $archiveCommand, array $cont $script = "set -eu\n" .'state_file='.escapeshellarg($stateFile)."\n" .": > \"\$state_file\"\n" - ."cleanup() { status=\$?; trap - EXIT HUP INT TERM; while IFS= read -r container; do docker unpause \"\$container\" >/dev/null || status=1; done < \"\$state_file\"; [ \$status -eq 0 ] && rm -f \"\$state_file\"; exit \$status; }\n" - ."trap cleanup EXIT HUP INT TERM\n" - ."for container in {$containerList}; do echo \"\$container\" >> \"\$state_file\"; docker pause \"\$container\" >/dev/null; done\n" + ."cleanup() { status=\$?; trap - EXIT HUP INT TERM; while IFS= read -r container; do docker start \"\$container\" >/dev/null || status=1; done < \"\$state_file\"; [ \$status -eq 0 ] && rm -f \"\$state_file\"; exit \$status; }\n" + ."trap cleanup EXIT\n" + ."trap 'exit 129' HUP\n" + ."trap 'exit 130' INT\n" + ."trap 'exit 143' TERM\n" + ."for container in {$containerList}; do echo \"\$container\" >> \"\$state_file\"; docker stop \"\$container\" >/dev/null; done\n" .$archiveCommand; return 'sh -c '.escapeshellarg($script); @@ -273,7 +276,7 @@ private function archiveWithPausedContainers(string $archiveCommand, array $cont private function recoverIncompleteBackup(ScheduledVolumeBackupExecution $execution): string { - if (! $execution->pause_recovery_pending && ! $execution->s3_cleanup_pending) { + if (! $execution->stop_recovery_pending && ! $execution->s3_cleanup_pending) { return ''; } diff --git a/app/Jobs/VolumeBackupRecoveryJob.php b/app/Jobs/VolumeBackupRecoveryJob.php index 1749b5309..44e7d47cb 100644 --- a/app/Jobs/VolumeBackupRecoveryJob.php +++ b/app/Jobs/VolumeBackupRecoveryJob.php @@ -48,7 +48,7 @@ public static function recover(ScheduledVolumeBackupExecution $execution): void { $execution->loadMissing('scheduledVolumeBackup.volume.resource'); - if ($execution->pause_recovery_pending) { + if ($execution->stop_recovery_pending) { self::recoverContainers($execution); } @@ -75,22 +75,22 @@ private static function recoverContainers(ScheduledVolumeBackupExecution $execut ->filter(fn (string $container): bool => preg_match('/^[a-f0-9]{6,64}$/i', $container) === 1) ->values() ->all(); - $execution->update(['pause_container_ids' => $containers]); + $execution->update(['stop_container_ids' => $containers]); $remainingFile = $stateFile.'.remaining'; $script = 'status=0; : > '.escapeshellarg($remainingFile).'; ' .'if [ -f '.escapeshellarg($stateFile).' ]; then while IFS= read -r container; do ' - .'[ -z "$container" ] && continue; paused=$(docker inspect --format \'{{.State.Paused}}\' "$container" 2>/dev/null) ' + .'[ -z "$container" ] && continue; running=$(docker inspect --format \'{{.State.Running}}\' "$container" 2>/dev/null) ' .'|| { echo "$container" >> '.escapeshellarg($remainingFile).'; status=1; continue; }; ' - .'if [ "$paused" = true ] && ! docker unpause "$container" >/dev/null; then echo "$container" >> ' + .'if [ "$running" != true ] && ! docker start "$container" >/dev/null; then echo "$container" >> ' .escapeshellarg($remainingFile).'; status=1; fi; done < '.escapeshellarg($stateFile).'; fi; ' .'if [ -s '.escapeshellarg($remainingFile).' ]; then mv '.escapeshellarg($remainingFile).' '.escapeshellarg($stateFile) .'; else rm -f '.escapeshellarg($stateFile).' '.escapeshellarg($remainingFile).'; fi; exit $status'; instant_remote_process(['sh -c '.escapeshellarg($script)], $server, disableMultiplexing: true); $execution->update([ - 'pause_container_ids' => null, - 'pause_recovery_pending' => false, + 'stop_container_ids' => null, + 'stop_recovery_pending' => false, ]); } @@ -112,6 +112,6 @@ public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution public static function stateFile(ScheduledVolumeBackupExecution $execution): string { - return '/tmp/coolify-volume-backup-'.$execution->uuid.'.paused'; + return '/tmp/coolify-volume-backup-'.$execution->uuid.'.stopped'; } } diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index 63441ba34..6041f139f 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -32,7 +32,7 @@ class VolumeBackups extends Component public bool $disableLocalBackup = false; - public bool $pauseDuringBackup = false; + public bool $stopDuringBackup = false; public ?int $s3StorageId = null; @@ -63,7 +63,7 @@ protected function rules(): array 'enabled' => ['required', 'boolean'], 'saveToS3' => ['required', 'boolean'], 'disableLocalBackup' => ['required', 'boolean'], - 'pauseDuringBackup' => ['required', 'boolean'], + 'stopDuringBackup' => ['required', 'boolean'], 's3StorageId' => ['nullable', 'integer'], 'retentionAmountLocally' => ['required', 'integer', 'min:0', 'max:10000'], 'retentionDaysLocally' => ['required', 'integer', 'min:0'], @@ -90,7 +90,7 @@ public function mount(): void $this->enabled = $this->backup->enabled; $this->saveToS3 = $this->backup->save_s3; $this->disableLocalBackup = $this->backup->disable_local_backup; - $this->pauseDuringBackup = $this->backup->pause_during_backup; + $this->stopDuringBackup = $this->backup->stop_during_backup; $this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3Storages->first()?->id; $this->retentionAmountLocally = $this->backup->retention_amount_locally; $this->retentionDaysLocally = $this->backup->retention_days_locally; @@ -188,7 +188,7 @@ public function delete(?string $password = null, array $selectedActions = []) if ($this->backup->executions() ->where(fn ($query) => $query ->where('status', 'running') - ->orWhere('pause_recovery_pending', true) + ->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.'); @@ -246,7 +246,7 @@ public function cleanupFailed(): void $deletedCount = $this->backup?->executions() ->where('status', 'failed') - ->where('pause_recovery_pending', false) + ->where('stop_recovery_pending', false) ->where('s3_cleanup_pending', false) ->where(fn ($query) => $query ->whereNull('filename') @@ -292,7 +292,7 @@ public function deleteBackup(int $executionId, string $password, array $selected return false; } - if ($execution->status === 'running' || $execution->pause_recovery_pending || $execution->s3_cleanup_pending) { + if ($execution->status === 'running' || $execution->stop_recovery_pending || $execution->s3_cleanup_pending) { $this->dispatch('error', 'Wait for the backup and recovery operations to finish before deleting it.'); return false; @@ -369,7 +369,7 @@ private function persistBackup(bool $enabled): ScheduledVolumeBackup 'enabled' => $enabled, 'save_s3' => $this->saveToS3, 'disable_local_backup' => $this->disableLocalBackup, - 'pause_during_backup' => $this->pauseDuringBackup, + 'stop_during_backup' => $this->stopDuringBackup, 's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null, 'retention_amount_locally' => $this->retentionAmountLocally, 'retention_days_locally' => $this->retentionDaysLocally, diff --git a/app/Models/ScheduledVolumeBackup.php b/app/Models/ScheduledVolumeBackup.php index 23e2369b1..055230332 100644 --- a/app/Models/ScheduledVolumeBackup.php +++ b/app/Models/ScheduledVolumeBackup.php @@ -17,7 +17,7 @@ class ScheduledVolumeBackup extends BaseModel 'enabled', 'save_s3', 'disable_local_backup', - 'pause_during_backup', + 'stop_during_backup', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', @@ -33,7 +33,7 @@ protected function casts(): array 'enabled' => 'boolean', 'save_s3' => 'boolean', 'disable_local_backup' => 'boolean', - 'pause_during_backup' => 'boolean', + 'stop_during_backup' => 'boolean', 'retention_amount_locally' => 'integer', 'retention_days_locally' => 'integer', 'retention_max_storage_locally' => 'float', diff --git a/app/Models/ScheduledVolumeBackupExecution.php b/app/Models/ScheduledVolumeBackupExecution.php index b15294461..1bcf5e6bc 100644 --- a/app/Models/ScheduledVolumeBackupExecution.php +++ b/app/Models/ScheduledVolumeBackupExecution.php @@ -13,8 +13,8 @@ class ScheduledVolumeBackupExecution extends BaseModel 'message', 'size', 'filename', - 'pause_container_ids', - 'pause_recovery_pending', + 'stop_container_ids', + 'stop_recovery_pending', 's3_cleanup_pending', 'finished_at', 'local_storage_deleted', @@ -27,8 +27,8 @@ protected function casts(): array return [ 'size' => 'integer', 'finished_at' => 'datetime', - 'pause_container_ids' => 'array', - 'pause_recovery_pending' => 'boolean', + 'stop_container_ids' => 'array', + 'stop_recovery_pending' => 'boolean', 's3_cleanup_pending' => 'boolean', 'local_storage_deleted' => 'boolean', 's3_storage_deleted' => 'boolean', 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 6ebf1a80e..f5d49c990 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 @@ -21,7 +21,7 @@ public function up(): void $table->boolean('enabled')->default(true); $table->boolean('save_s3')->default(false); $table->boolean('disable_local_backup')->default(false); - $table->boolean('pause_during_backup')->default(false); + $table->boolean('stop_during_backup')->default(false); $table->unsignedInteger('retention_amount_locally')->default(7); $table->unsignedInteger('retention_amount_s3')->default(7); $table->unsignedInteger('timeout')->default(3600); diff --git a/database/migrations/2026_07_15_102538_create_scheduled_volume_backup_executions_table.php b/database/migrations/2026_07_15_102538_create_scheduled_volume_backup_executions_table.php index 20f5fc273..a8a044373 100644 --- a/database/migrations/2026_07_15_102538_create_scheduled_volume_backup_executions_table.php +++ b/database/migrations/2026_07_15_102538_create_scheduled_volume_backup_executions_table.php @@ -19,8 +19,8 @@ public function up(): void $table->longText('message')->nullable(); $table->unsignedBigInteger('size')->default(0); $table->text('filename')->nullable(); - $table->json('pause_container_ids')->nullable(); - $table->boolean('pause_recovery_pending')->default(false); + $table->json('stop_container_ids')->nullable(); + $table->boolean('stop_recovery_pending')->default(false); $table->boolean('s3_cleanup_pending')->default(false); $table->timestamp('finished_at')->nullable(); $table->boolean('local_storage_deleted')->default(false); diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index b8e9e8293..6b112ede3 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -65,6 +65,15 @@
@endcan @endif + @if ($resource instanceof \App\Models\Application) + @can('update', $resource) + + + + @endcan + @endif @else @can('update', $resource) @if ($isFirst) @@ -150,15 +159,4 @@ @endcan @endif - @if ($isReadOnly && $resource instanceof \App\Models\Application) - @can('update', $resource) -
- - - -
- @endcan - @endif
diff --git a/resources/views/livewire/project/shared/storages/volume-backups.blade.php b/resources/views/livewire/project/shared/storages/volume-backups.blade.php index 8549a14c5..58aed0a0f 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups.blade.php @@ -33,12 +33,16 @@ {{ $storage->name }}

- +
+ Backups made while the application is writing to this volume may be inconsistent or corrupted. You can + gracefully stop containers during the archive step for a safer file-level backup, but this briefly + interrupts the application. +
- + @if ($availableS3Storages->isNotEmpty()) @elseif ($saveToS3) @@ -80,11 +84,7 @@

Settings

-
- Backups made while the application is writing to this volume may be inconsistent or corrupted. You can - pause containers during the archive step for a more consistent backup, but this briefly interrupts the - application. -
+
diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 94bde7b32..e12b4f278 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -96,18 +96,25 @@ ->assertSee($volume->name); }); -it('shows the configure backup modal trigger instead of inline backup settings on a volume', function () { +it('shows the configure backup modal trigger inside the volume card instead of inline backup settings', function () { InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); - Livewire::test(Show::class, [ + $component = Livewire::test(Show::class, [ 'storage' => $volume, 'resource' => $application, ]) + ->set('isReadOnly', true) ->assertSee('Configure Backup') ->assertDontSee('Backups made while the application is writing'); + + $html = $component->html(); + + expect(strpos($html, 'Configure Backup')) + ->toBeGreaterThan(strpos($html, 'toBeLessThan(strpos($html, '')); }); it('only shows the backup enabled badge for an enabled volume backup', function () { @@ -151,7 +158,7 @@ 'enabled', 'save_s3', 'disable_local_backup', - 'pause_during_backup', + 'stop_during_backup', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', @@ -167,8 +174,8 @@ 'message', 'size', 'filename', - 'pause_container_ids', - 'pause_recovery_pending', + 'stop_container_ids', + 'stop_recovery_pending', 's3_cleanup_pending', 'finished_at', 'local_storage_deleted', @@ -215,7 +222,7 @@ 'Backup Now', 'Delete Backups and Schedule', 'Persistent volume:', - 'Pause containers while creating the archive', + 'Stop containers while creating the archive', 'S3 Enabled', 'Disable Local Backup', 'S3 Storage', @@ -397,11 +404,12 @@ function signInForVolumeBackups($testCase, Team $team): User ->assertSee('Scheduled Backup') ->assertSee('Persistent volume:') ->assertSee('inconsistent or corrupted') + ->assertSee('gracefully stop containers') ->set('frequency', 'daily') ->set('retentionAmountLocally', 5) ->set('retentionDaysLocally', 14) ->set('retentionMaxStorageLocally', 1.5) - ->set('pauseDuringBackup', true) + ->set('stopDuringBackup', true) ->call('save') ->assertDispatched('success'); @@ -413,7 +421,7 @@ function signInForVolumeBackups($testCase, Team $team): User ->and($backup->retention_amount_locally)->toBe(5) ->and($backup->retention_days_locally)->toBe(14) ->and($backup->retention_max_storage_locally)->toBe(1.5) - ->and($backup->pause_during_backup)->toBeTrue() + ->and($backup->stop_during_backup)->toBeTrue() ->and($backup->save_s3)->toBeFalse(); }); @@ -487,6 +495,45 @@ function signInForVolumeBackups($testCase, Team $team): User ->and($backup->fresh()->disable_local_backup)->toBeFalse(); }); +it('saves each editable volume backup checkbox immediately', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $backup = ScheduledVolumeBackup::create([ + 'local_persistent_volume_id' => $volume->id, + 'team_id' => $team->id, + 's3_storage_id' => $s3Storage->id, + 'frequency' => 'daily', + 'save_s3' => true, + ]); + + $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]); + $html = $component->html(); + + foreach (['stopDuringBackup', 'saveToS3', 'disableLocalBackup'] as $property) { + preg_match('/]*wire:model=(?:"'.$property.'"|'.$property.'))[^>]*>/', $html, $matches); + + expect($matches[0] ?? null)->not->toBeNull() + ->and($matches[0])->toContain("wire:click='instantSave'"); + } + + $component->set('stopDuringBackup', true)->call('instantSave')->assertDispatched('success'); + expect($backup->refresh()->stop_during_backup)->toBeTrue(); + + $component->set('stopDuringBackup', false)->call('instantSave')->assertDispatched('success'); + expect($backup->refresh()->stop_during_backup)->toBeFalse(); +}); + it('allows volume S3 backups to be disabled when no usable storage remains', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); @@ -829,7 +876,7 @@ function signInForVolumeBackups($testCase, Team $team): User ->and($oldExecution->fresh()->local_storage_deleted)->toBeFalse(); }); -it('pauses and resumes containers that use the volume when requested', function () { +it('stops and restarts containers that use the volume when requested', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); @@ -838,7 +885,7 @@ function signInForVolumeBackups($testCase, Team $team): User 'local_persistent_volume_id' => $volume->id, 'team_id' => $team->id, 'frequency' => 'daily', - 'pause_during_backup' => true, + 'stop_during_backup' => true, ]); Process::fake([ @@ -849,9 +896,11 @@ function signInForVolumeBackups($testCase, Team $team): User (new VolumeBackupJob($backup))->handle(); - Process::assertRan(fn ($process) => str_contains($process->command, 'trap cleanup EXIT HUP INT TERM') - && str_contains($process->command, 'docker pause') - && str_contains($process->command, 'docker unpause') + Process::assertRan(fn ($process) => str_contains($process->command, 'trap cleanup EXIT') + && str_contains($process->command, 'exit 143') + && str_contains($process->command, 'TERM') + && str_contains($process->command, 'docker stop') + && str_contains($process->command, 'docker start') && str_contains($process->command, 'state_file=')); Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}') && str_contains($process->command, "grep -Fqx -- 'app-data'") @@ -859,7 +908,7 @@ function signInForVolumeBackups($testCase, Team $team): User && str_contains($process->command, 'continue')); }); -it('finds containers using a bind mounted host path before pausing', function () { +it('finds containers using a bind mounted host path before stopping', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); @@ -869,7 +918,7 @@ function signInForVolumeBackups($testCase, Team $team): User 'local_persistent_volume_id' => $volume->id, 'team_id' => $team->id, 'frequency' => 'daily', - 'pause_during_backup' => true, + 'stop_during_backup' => true, ]); Process::fake([ @@ -884,7 +933,7 @@ function signInForVolumeBackups($testCase, Team $team): User && str_contains($process->command, "grep -Fqx -- '/srv/app-data'")); }); -it('retries container recovery when a timed out backup left a container paused', function () { +it('retries container recovery when a timed out backup left a container stopped', function () { Queue::fake(); $team = Team::factory()->create(); [$application, $volume] = createVolumeBackupApplication($team); @@ -896,23 +945,23 @@ function signInForVolumeBackups($testCase, Team $team): User $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'running', - 'pause_recovery_pending' => true, + 'stop_recovery_pending' => true, ]); Process::fake([ '*cat *coolify-volume-backup-*' => 'abc123', - '*docker unpause*' => Process::result(errorOutput: 'daemon unavailable', exitCode: 1), + '*docker start*' => Process::result(errorOutput: 'daemon unavailable', exitCode: 1), ]); (new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out')); expect($execution->fresh()->status)->toBe('failed') ->and($execution->fresh()->message)->toContain('Container recovery failed') - ->and($execution->fresh()->pause_container_ids)->toBe(['abc123']) - ->and($execution->fresh()->pause_recovery_pending)->toBeTrue(); + ->and($execution->fresh()->stop_container_ids)->toBe(['abc123']) + ->and($execution->fresh()->stop_recovery_pending)->toBeTrue(); Queue::assertPushed(VolumeBackupRecoveryJob::class); }); -it('resumes containers and removes a partial archive when archiving fails', function () { +it('restarts containers and removes a partial archive when archiving fails', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); @@ -921,11 +970,11 @@ function signInForVolumeBackups($testCase, Team $team): User 'local_persistent_volume_id' => $volume->id, 'team_id' => $team->id, 'frequency' => 'daily', - 'pause_during_backup' => true, + 'stop_during_backup' => true, ]); Process::fake([ '*docker ps -q*' => 'abc123', - '*trap cleanup EXIT HUP INT TERM*' => Process::result(errorOutput: 'archive failed', exitCode: 1), + '*trap cleanup EXIT*' => Process::result(errorOutput: 'archive failed', exitCode: 1), '*' => '', ]); @@ -933,7 +982,7 @@ function signInForVolumeBackups($testCase, Team $team): User $execution = ScheduledVolumeBackupExecution::query()->sole(); expect($execution->status)->toBe('failed') - ->and($execution->pause_container_ids)->toBeNull(); + ->and($execution->stop_container_ids)->toBeNull(); Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f') && str_contains($process->command, '.tar.gz')); }); @@ -1079,7 +1128,7 @@ function signInForVolumeBackups($testCase, Team $team): User $execution = ScheduledVolumeBackupExecution::create([ 'scheduled_volume_backup_id' => $backup->id, 'status' => 'failed', - 'pause_recovery_pending' => true, + 'stop_recovery_pending' => true, ]); (new ScheduledJobManager)->handle(); From d7385ad0c4bfa39e4e5155a6de20de73b17de0bc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:34:22 +0200 Subject: [PATCH 03/17] feat(backups): support scheduled backups for application storage targets Add polymorphic volume backup scheduling for persistent volumes and directories, expose schedule management via API, and reorganize backup configuration and execution views. --- .../Api/VolumeBackupsController.php | 314 ++++++++ app/Jobs/ScheduledJobManager.php | 7 +- app/Jobs/VolumeBackupJob.php | 23 +- app/Jobs/VolumeBackupRecoveryJob.php | 2 +- .../Project/Application/Backup/Create.php | 107 +-- .../Project/Application/Backup/Index.php | 8 +- .../Project/Application/Backup/Show.php | 13 +- .../Project/Database/Backup/Execution.php | 12 + app/Livewire/Project/Database/BackupEdit.php | 21 +- .../Database/CreateScheduledBackup.php | 57 +- .../Project/Database/ScheduledBackups.php | 28 - .../Project/Service/DatabaseBackups.php | 30 +- app/Livewire/Project/Service/FileStorage.php | 47 ++ app/Livewire/Project/Service/Storage.php | 5 + app/Livewire/Project/Shared/Storages/Show.php | 28 +- .../Project/Shared/Storages/VolumeBackups.php | 60 +- app/Models/LocalFileVolume.php | 20 +- app/Models/LocalPersistentVolume.php | 15 +- app/Models/ScheduledVolumeBackup.php | 75 +- .../ApplicationConfigurationSnapshot.php | 40 + ..._create_scheduled_volume_backups_table.php | 5 +- openapi.json | 409 ++++++++++ openapi.yaml | 286 +++++++ .../application/backup/create.blade.php | 28 +- .../application/backup/index.blade.php | 13 +- .../project/application/backup/show.blade.php | 34 +- .../project/application/heading.blade.php | 4 +- .../project/database/backup-edit.blade.php | 171 +---- .../database/backup-edit/danger.blade.php | 27 + .../database/backup-edit/general.blade.php | 62 ++ .../database/backup-edit/retention.blade.php | 43 ++ .../project/database/backup-edit/s3.blade.php | 45 ++ .../database/backup-executions.blade.php | 6 +- .../database/backup/execution.blade.php | 36 +- .../create-scheduled-backup.blade.php | 13 - .../project/database/heading.blade.php | 4 +- .../database/scheduled-backups.blade.php | 19 +- .../service/database-backups.blade.php | 64 +- .../project/service/file-storage.blade.php | 29 +- .../project/service/heading.blade.php | 2 +- .../shared/configuration-checker.blade.php | 1 + .../project/shared/storages/show.blade.php | 32 +- .../shared/storages/volume-backups.blade.php | 339 +-------- .../storages/volume-backups/danger.blade.php | 24 + .../volume-backups/executions.blade.php | 183 +++++ .../storages/volume-backups/general.blade.php | 47 ++ .../volume-backups/retention.blade.php | 33 + .../storages/volume-backups/s3.blade.php | 60 ++ routes/api.php | 10 + routes/web.php | 15 +- .../Api/VolumeBackupScheduleApiTest.php | 353 +++++++++ tests/Feature/BackupEditValidationTest.php | 151 +++- tests/Feature/BackupSearchTest.php | 9 +- .../CreateScheduledBackupValidationTest.php | 157 ++-- tests/Feature/ExecutionViewStructureTest.php | 26 + tests/Feature/FileStorageMountPathTest.php | 88 ++- .../Livewire/ConfigurationCheckerTest.php | 25 + tests/Feature/ServiceResourceRoutingTest.php | 63 ++ tests/Feature/VolumeBackupTest.php | 696 +++++++++++++++--- .../ApplicationConfigurationSnapshotTest.php | 31 + 60 files changed, 3570 insertions(+), 985 deletions(-) create mode 100644 app/Http/Controllers/Api/VolumeBackupsController.php create mode 100644 resources/views/livewire/project/database/backup-edit/danger.blade.php create mode 100644 resources/views/livewire/project/database/backup-edit/general.blade.php create mode 100644 resources/views/livewire/project/database/backup-edit/retention.blade.php create mode 100644 resources/views/livewire/project/database/backup-edit/s3.blade.php create mode 100644 resources/views/livewire/project/shared/storages/volume-backups/danger.blade.php create mode 100644 resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php create mode 100644 resources/views/livewire/project/shared/storages/volume-backups/general.blade.php create mode 100644 resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php create mode 100644 resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php create mode 100644 tests/Feature/Api/VolumeBackupScheduleApiTest.php create mode 100644 tests/Feature/ExecutionViewStructureTest.php 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)