From 58db0eba6f2860317b09a04b8ba8664981cccbcf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:24:10 +0200 Subject: [PATCH] feat(backups): notify teams of volume backup results Send success and failure notifications through enabled backup channels. Route successful backups with warnings through failure channels. --- app/Jobs/VolumeBackupJob.php | 9 ++ .../VolumeBackup/BackupFailed.php | 123 +++++++++++++++++ .../VolumeBackup/BackupSuccess.php | 124 ++++++++++++++++++ .../emails/volume-backup-result.blade.php | 15 +++ tests/Feature/VolumeBackupTest.php | 64 +++++++++ 5 files changed, 335 insertions(+) create mode 100644 app/Notifications/VolumeBackup/BackupFailed.php create mode 100644 app/Notifications/VolumeBackup/BackupSuccess.php create mode 100644 resources/views/emails/volume-backup-result.blade.php diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index f142a65941..d635894b09 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -7,6 +7,8 @@ use App\Models\LocalPersistentVolume; use App\Models\ScheduledVolumeBackup; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; +use App\Notifications\VolumeBackup\BackupFailed; +use App\Notifications\VolumeBackup\BackupSuccess; use App\Rules\SafeWebhookUrl; use App\Services\ScheduledJobDeliveryService; use App\Support\BackupCompression; @@ -238,6 +240,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $this->execution->update(['finished_at' => now()]); BackupCreated::dispatch($team->id); } + + $team->notify(new BackupSuccess($this->backup, $warning)); } public function failed(?Throwable $exception): void @@ -277,6 +281,11 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue 'local_storage_deleted' => $localStorageDeleted, ]); } + + $this->backup->team?->notify(new BackupFailed( + $this->backup, + $exception?->getMessage() ?? 'Volume backup timed out or was terminated.', + )); } /** diff --git a/app/Notifications/VolumeBackup/BackupFailed.php b/app/Notifications/VolumeBackup/BackupFailed.php new file mode 100644 index 0000000000..781a7b63e3 --- /dev/null +++ b/app/Notifications/VolumeBackup/BackupFailed.php @@ -0,0 +1,123 @@ +onQueue('high'); + $resource = $backup->targetResource(); + $this->resourceName = $resource?->name ?? 'Unknown resource'; + $this->target = $backup->targetType().' '.$backup->targetName(); + $this->frequency = $backup->frequency; + + $linkable = $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase ? $resource->service : $resource; + if ($linkable && method_exists($linkable, 'link')) { + $this->url = $linkable->link(); + } + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failure'); + } + + public function summary(): string + { + return "Storage backup of {$this->target} ({$this->resourceName}) has FAILED."; + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: [ACTION REQUIRED] Storage backup FAILED for {$this->resourceName}"); + $mail->view('emails.volume-backup-result', [ + 'summary' => $this->summary(), + 'frequency' => $this->frequency, + 'output' => $this->output, + 'url' => $this->url, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: ':cross_mark: Storage backup failed', + description: $this->summary(), + color: DiscordMessage::errorColor(), + isCritical: true, + ); + + $message->addField('Frequency', $this->frequency, true); + $message->addField('Output', $this->output); + if ($this->url) { + $message->addField('Resource', '[Link]('.$this->url.')'); + } + + return $message; + } + + public function toTelegram(): array + { + $data = ['message' => "Coolify: {$this->summary()}\n\nFrequency: {$this->frequency}\n\nReason:\n{$this->output}"]; + + if ($this->url) { + $data['buttons'] = [['text' => 'Open resource in Coolify', 'url' => $this->url]]; + } + + return $data; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage( + title: 'Storage backup failed', + level: 'error', + message: "{$this->summary()}

Frequency: {$this->frequency}.
Reason: {$this->output}", + buttons: $this->url ? [['text' => 'Open resource in Coolify', 'url' => $this->url]] : [], + ); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage( + title: 'Storage backup failed', + description: $this->summary()."\n\n*Frequency:* {$this->frequency}\n\n*Error Output:* {$this->output}".($this->url ? "\n\n<{$this->url}|Open resource in Coolify>" : ''), + color: SlackMessage::errorColor(), + ); + } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'Storage backup failed', + 'event' => 'volume_backup_failed', + 'resource_name' => $this->resourceName, + 'storage' => $this->target, + 'frequency' => $this->frequency, + 'error_output' => $this->output, + 'url' => $this->url, + ]; + } +} diff --git a/app/Notifications/VolumeBackup/BackupSuccess.php b/app/Notifications/VolumeBackup/BackupSuccess.php new file mode 100644 index 0000000000..4ff192971a --- /dev/null +++ b/app/Notifications/VolumeBackup/BackupSuccess.php @@ -0,0 +1,124 @@ +onQueue('high'); + $resource = $backup->targetResource(); + $this->resourceName = $resource?->name ?? 'Unknown resource'; + $this->target = $backup->targetType().' '.$backup->targetName(); + $this->frequency = $backup->frequency; + + $linkable = $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase ? $resource->service : $resource; + if ($linkable && method_exists($linkable, 'link')) { + $this->url = $linkable->link(); + } + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels($this->warning ? 'backup_failure' : 'backup_success'); + } + + public function summary(): string + { + $summary = "Storage backup of {$this->target} ({$this->resourceName}) was successful."; + + return $this->warning ? $summary." Warning: {$this->warning}" : $summary; + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject($this->warning + ? "Coolify: Storage backup succeeded with a warning for {$this->resourceName}" + : "Coolify: Storage backup successfully done for {$this->resourceName}"); + $mail->view('emails.volume-backup-result', [ + 'summary' => $this->summary(), + 'frequency' => $this->frequency, + 'url' => $this->url, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: $this->warning ? ':warning: Storage backup succeeded with a warning' : ':white_check_mark: Storage backup successful', + description: $this->summary(), + color: $this->warning ? DiscordMessage::warningColor() : DiscordMessage::successColor(), + ); + + $message->addField('Frequency', $this->frequency, true); + if ($this->url) { + $message->addField('Resource', '[Link]('.$this->url.')'); + } + + return $message; + } + + public function toTelegram(): array + { + $data = ['message' => "Coolify: {$this->summary()}\n\nFrequency: {$this->frequency}"]; + + if ($this->url) { + $data['buttons'] = [['text' => 'Open resource in Coolify', 'url' => $this->url]]; + } + + return $data; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage( + title: $this->warning ? 'Storage backup succeeded with a warning' : 'Storage backup successful', + level: $this->warning ? 'warning' : 'success', + message: "{$this->summary()}

Frequency: {$this->frequency}.", + buttons: $this->url ? [['text' => 'Open resource in Coolify', 'url' => $this->url]] : [], + ); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage( + title: $this->warning ? 'Storage backup succeeded with a warning' : 'Storage backup successful', + description: $this->summary()."\n\n*Frequency:* {$this->frequency}".($this->url ? "\n\n<{$this->url}|Open resource in Coolify>" : ''), + color: $this->warning ? SlackMessage::warningColor() : SlackMessage::successColor(), + ); + } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => $this->warning ? 'Storage backup succeeded with a warning' : 'Storage backup successful', + 'event' => 'volume_backup_success', + 'resource_name' => $this->resourceName, + 'storage' => $this->target, + 'frequency' => $this->frequency, + 'warning' => $this->warning, + 'url' => $this->url, + ]; + } +} diff --git a/resources/views/emails/volume-backup-result.blade.php b/resources/views/emails/volume-backup-result.blade.php new file mode 100644 index 0000000000..b271e11097 --- /dev/null +++ b/resources/views/emails/volume-backup-result.blade.php @@ -0,0 +1,15 @@ + +{{ $summary }} + +Frequency: {{ $frequency }} + +@isset($output) +### Reason + +{{ $output }} +@endisset + +@if ($url) +Click [here]({{ $url }}) to open the resource. +@endif + diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 2b440eaac2..75fbe6b9c6 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -27,6 +27,8 @@ use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\Team; use App\Models\User; +use App\Notifications\VolumeBackup\BackupFailed; +use App\Notifications\VolumeBackup\BackupSuccess; use Illuminate\Database\Eloquent\Collection; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -37,6 +39,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Route; @@ -2786,3 +2789,64 @@ it('stops S3 upload cleanup when the S3 storage no longer exists', function () { ->and($execution->fresh()->s3_storage_deleted)->toBeFalsy() ->and($execution->fresh()->message)->toContain('S3 upload cleanup was skipped'); }); + +it('notifies the team when a volume backup succeeds', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + Notification::fake(); + $team = Team::factory()->create(); + $team->discordNotificationSettings()->update(['discord_enabled' => true, 'backup_success_discord_notifications' => true, 'backup_failure_discord_notifications' => true]); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + Process::fake([ + '*du -b*' => '128', + '*' => '', + ]); + + (new VolumeBackupJob($backup))->handle(); + + Notification::assertSentToTimes($team, BackupSuccess::class, 1); + Notification::assertSentTo($team, BackupSuccess::class, fn (BackupSuccess $notification) => $notification->warning === null + && $notification->resourceName === $application->name + && $notification->target === 'Volume '.$volume->name + && str_contains((string) $notification->toMail()->render(), 'was successful')); + Notification::assertNotSentTo($team, BackupFailed::class); +}); + +it('notifies the team once when a volume backup fails', function () { + Process::fake(); + Notification::fake(); + $team = Team::factory()->create(); + $team->discordNotificationSettings()->update(['discord_enabled' => true, 'backup_success_discord_notifications' => true, 'backup_failure_discord_notifications' => true]); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + + (new VolumeBackupJob($backup))->failed(new RuntimeException('Archive command failed')); + + Notification::assertSentToTimes($team, BackupFailed::class, 1); + Notification::assertSentTo($team, BackupFailed::class, fn (BackupFailed $notification) => $notification->output === 'Archive command failed' + && str_contains((string) $notification->toMail()->render(), 'Archive command failed')); + Notification::assertNotSentTo($team, BackupSuccess::class); +}); + +it('sends volume backup success with a warning through the backup failure channels', function (?string $warning, string $event) { + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + ]); + $notifiable = Mockery::mock(); + $notifiable->shouldReceive('getEnabledChannels')->once()->with($event)->andReturn([]); + + (new BackupSuccess($backup, $warning))->via($notifiable); +})->with([ + 'without warning' => [null, 'backup_success'], + 'with warning' => ['S3 upload failed: denied', 'backup_failure'], +]);