feat: implement Hetzner deletion failure notification system with email and messaging support

This commit is contained in:
Andras Bacsai 2025-10-10 09:35:58 +02:00
parent bbaef03602
commit 513f6b54f7
5 changed files with 205 additions and 0 deletions

View file

@ -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()));
}
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Notifications\Server;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class HetznerDeletionFailed extends CustomEmailNotification
{
public function __construct(public int $hetznerServerId, public int $teamId, public string $errorMessage)
{
$this->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()
);
}
}

View file

@ -17,6 +17,7 @@ trait HasNotificationSettings
'general',
'test',
'ssl_certificate_renewal',
'hetzner_deletion_failure',
];
/**

View file

@ -0,0 +1,13 @@
<x-emails.layout>
Failed to delete Hetzner server #{{ $hetznerServerId }} from Hetzner Cloud.
Error:
<pre>
{{ $errorMessage }}
</pre>
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.
</x-emails.layout>

View file

@ -0,0 +1,114 @@
<?php
use App\Notifications\Server\HetznerDeletionFailed;
use Mockery;
afterEach(function () {
Mockery::close();
});
it('can be instantiated with correct properties', function () {
$notification = new HetznerDeletionFailed(
hetznerServerId: 12345,
teamId: 1,
errorMessage: 'Hetzner API error: Server not found'
);
expect($notification)->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');
});