diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 1b4302911..45ec68abc 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -4,6 +4,8 @@ use App\Models\CloudProviderToken; use App\Models\Server; +use App\Models\Team; +use App\Notifications\Server\HetznerDeletionFailed; use App\Services\HetznerService; use Lorisleiva\Actions\Concerns\AsAction; @@ -92,6 +94,10 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider 'hetzner_server_id' => $hetznerServerId, 'team_id' => $teamId, ]); + + // Notify the team about the failure + $team = Team::find($teamId); + $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage())); } } } diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php new file mode 100644 index 000000000..de894331b --- /dev/null +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -0,0 +1,71 @@ +onQueue('high'); + } + + public function via(object $notifiable): array + { + ray('hello'); + ray($notifiable); + + return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}"); + $mail->view('emails.hetzner-deletion-failed', [ + 'hetznerServerId' => $this->hetznerServerId, + 'errorMessage' => $this->errorMessage, + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':cross_mark: Coolify: [ACTION REQUIRED] Failed to delete Hetzner server', + description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\n**Error:** {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + color: DiscordMessage::errorColor(), + ); + } + + public function toTelegram(): array + { + return [ + 'message' => "Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + ]; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage( + title: 'Hetzner Server Deletion Failed', + level: 'error', + message: "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId}.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check and manually delete if needed.", + ); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage( + title: 'Coolify: [ACTION REQUIRED] Hetzner Server Deletion Failed', + description: "Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud.\n\nError: {$this->errorMessage}\n\nThe server has been removed from Coolify, but may still exist in your Hetzner Cloud account. Please check your Hetzner Cloud console and manually delete the server if needed.", + color: SlackMessage::errorColor() + ); + } +} diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php index 236e4d97c..2c1b4a68c 100644 --- a/app/Traits/HasNotificationSettings.php +++ b/app/Traits/HasNotificationSettings.php @@ -17,6 +17,7 @@ trait HasNotificationSettings 'general', 'test', 'ssl_certificate_renewal', + 'hetzner_deletion_failure', ]; /** diff --git a/resources/views/emails/hetzner-deletion-failed.blade.php b/resources/views/emails/hetzner-deletion-failed.blade.php new file mode 100644 index 000000000..32995b11a --- /dev/null +++ b/resources/views/emails/hetzner-deletion-failed.blade.php @@ -0,0 +1,13 @@ + +Failed to delete Hetzner server #{{ $hetznerServerId }} from Hetzner Cloud. + +Error: +
+{{ $errorMessage }}
+
+ +The server has been removed from Coolify, but may still exist in your Hetzner Cloud account. + +Please check your Hetzner Cloud console and manually delete the server if needed to avoid ongoing charges. + +
diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php new file mode 100644 index 000000000..6cb9f0bb3 --- /dev/null +++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php @@ -0,0 +1,114 @@ +toBeInstanceOf(HetznerDeletionFailed::class) + ->and($notification->hetznerServerId)->toBe(12345) + ->and($notification->teamId)->toBe(1) + ->and($notification->errorMessage)->toBe('Hetzner API error: Server not found'); +}); + +it('uses hetzner_deletion_failed event for channels', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 12345, + teamId: 1, + errorMessage: 'Test error' + ); + + $mockNotifiable = Mockery::mock(); + $mockNotifiable->shouldReceive('getEnabledChannels') + ->with('hetzner_deletion_failed') + ->once() + ->andReturn([]); + + $channels = $notification->via($mockNotifiable); + + expect($channels)->toBeArray(); +}); + +it('generates correct mail content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 67890, + teamId: 1, + errorMessage: 'Connection timeout' + ); + + $mail = $notification->toMail(); + + expect($mail->subject)->toBe('Coolify: [ACTION REQUIRED] Failed to delete Hetzner server #67890') + ->and($mail->view)->toBe('emails.hetzner-deletion-failed') + ->and($mail->viewData['hetznerServerId'])->toBe(67890) + ->and($mail->viewData['errorMessage'])->toBe('Connection timeout'); +}); + +it('generates correct discord content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 11111, + teamId: 1, + errorMessage: 'API rate limit exceeded' + ); + + $discord = $notification->toDiscord(); + + expect($discord->title)->toContain('Failed to delete Hetzner server') + ->and($discord->description)->toContain('#11111') + ->and($discord->description)->toContain('API rate limit exceeded') + ->and($discord->description)->toContain('may still exist in your Hetzner Cloud account'); +}); + +it('generates correct telegram content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 22222, + teamId: 1, + errorMessage: 'Invalid token' + ); + + $telegram = $notification->toTelegram(); + + expect($telegram)->toBeArray() + ->and($telegram)->toHaveKey('message') + ->and($telegram['message'])->toContain('#22222') + ->and($telegram['message'])->toContain('Invalid token') + ->and($telegram['message'])->toContain('ACTION REQUIRED'); +}); + +it('generates correct pushover content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 33333, + teamId: 1, + errorMessage: 'Network error' + ); + + $pushover = $notification->toPushover(); + + expect($pushover->title)->toBe('Hetzner Server Deletion Failed') + ->and($pushover->level)->toBe('error') + ->and($pushover->message)->toContain('#33333') + ->and($pushover->message)->toContain('Network error'); +}); + +it('generates correct slack content', function () { + $notification = new HetznerDeletionFailed( + hetznerServerId: 44444, + teamId: 1, + errorMessage: 'Permission denied' + ); + + $slack = $notification->toSlack(); + + expect($slack->title)->toContain('Hetzner Server Deletion Failed') + ->and($slack->description)->toContain('#44444') + ->and($slack->description)->toContain('Permission denied'); +});