+ Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. +
+{{ $hetznerSearchError }}
++ @if ($manualHetznerServerId) + No Hetzner server found with ID: {{ $manualHetznerServerId }} + @else + No Hetzner server found matching IP: {{ $server->ip }} + @endif +
++ Try a different token, enter the Server ID manually, or verify the details are correct. +
++ Link this server to a DigitalOcean droplet to enable power controls and status monitoring. +
+{{ $digitalOceanSearchError }}
++ @if ($manualDigitalOceanDropletId) + No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} + @else + No DigitalOcean droplet found matching IP: {{ $server->ip }} + @endif +
++ Try a different token, enter the Droplet ID manually, or verify the details are correct. +
++ Link this server to a Vultr instance to enable power controls and status monitoring. +
+{{ $vultrSearchError }}
++ @if ($manualVultrInstanceId) + No Vultr instance found with ID: {{ $manualVultrInstanceId }} + @else + No Vultr instance found matching IP: {{ $server->ip }} + @endif +
++ Try a different token, enter the Instance ID manually, or verify the details are correct. +
+- Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. -
- -{{ $hetznerSearchError }}
-- @if ($manualHetznerServerId) - No Hetzner server found with ID: {{ $manualHetznerServerId }} - @else - No Hetzner server found matching IP: {{ $server->ip }} - @endif -
-- Try a different token, enter the Server ID manually, or verify the details are correct. -
-- Link this server to a Vultr instance to enable power controls and status monitoring. -
- -{{ $vultrSearchError }}
-- @if ($manualVultrInstanceId) - No Vultr instance found with ID: {{ $manualVultrInstanceId }} - @else - No Vultr instance found matching IP: {{ $server->ip }} - @endif -
-- Try a different token, enter the Instance ID manually, or verify the details are correct. -
-- Link this server to a DigitalOcean droplet to enable power controls and status monitoring. -
- -{{ $digitalOceanSearchError }}
-- @if ($manualDigitalOceanDropletId) - No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} - @else - No DigitalOcean droplet found matching IP: {{ $server->ip }} - @endif -
-Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeFalse(); +}); + +it('allows different recipients and content through the default fingerprint', function () { + $notification = new DedupeTestNotification; + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Other body
'))->toBeTrue(); +}); + +it('uses semantic keys instead of rendered content when provided', function () { + $notification = new DedupeTestNotification(semanticKey: 'event:123'); + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', 'Other body
'))->toBeFalse() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Other subject', 'Other body
'))->toBeTrue(); +}); + +it('allows notifications to opt out of deduplication', function () { + $notification = new DedupeTestNotification(deduplicate: false); + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue(); +}); From 2a0183bfad2ab7252144e5d73eb8cf7b598a0625 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:19:48 +0200 Subject: [PATCH 116/125] Revert "feat(notifications): deduplicate repeated email alerts" This reverts commit 8c1405e1689c7f26e27f062bb35025f76df02b05. --- .../ApiTokenExpiringNotification.php | 10 -- .../Application/DeploymentFailed.php | 10 -- .../Application/DeploymentSuccess.php | 10 -- .../Application/RestartLimitReached.php | 10 -- .../Application/StatusChanged.php | 11 --- app/Notifications/Channels/EmailChannel.php | 38 +++----- .../Channels/TransactionalEmailChannel.php | 11 +-- .../Container/ContainerRestarted.php | 10 -- .../Container/ContainerStopped.php | 10 -- app/Notifications/CustomEmailNotification.php | 15 --- app/Notifications/Database/BackupFailed.php | 13 --- app/Notifications/Database/BackupSuccess.php | 13 --- .../Database/BackupSuccessWithS3Warning.php | 13 --- .../ScheduledTask/TaskFailed.php | 10 -- .../ScheduledTask/TaskSuccess.php | 10 -- .../Server/DockerCleanupFailed.php | 10 -- .../Server/DockerCleanupSuccess.php | 10 -- app/Notifications/Server/ForceDisabled.php | 10 -- app/Notifications/Server/ForceEnabled.php | 10 -- .../Server/HetznerDeletionFailed.php | 10 -- app/Notifications/Server/HighDiskUsage.php | 10 -- app/Notifications/Server/Reachable.php | 10 -- app/Notifications/Server/ServerPatchCheck.php | 10 -- .../Server/TraefikVersionOutdated.php | 12 --- app/Notifications/Server/Unreachable.php | 10 -- .../SslExpirationNotification.php | 16 ---- app/Notifications/Test.php | 5 - .../EmailChangeVerification.php | 10 -- .../TransactionalEmails/InvitationLink.php | 10 -- .../TransactionalEmails/Test.php | 5 - app/Services/NotificationDeduplicator.php | 95 ------------------- ...pplicationStoppedAfterRestartLimitTest.php | 18 ---- .../Feature/NotificationDeduplicationTest.php | 75 --------------- 33 files changed, 14 insertions(+), 516 deletions(-) delete mode 100644 app/Services/NotificationDeduplicator.php delete mode 100644 tests/Feature/NotificationDeduplicationTest.php diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index c00ac2d12..451dd312a 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -29,16 +29,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('api_token_expiring'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "api-token-expiring:{$this->token->id}"; - } - - public function deduplicateFor(): int - { - return 172800; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index 0ed705edd..8fff7f03b 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -52,16 +52,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "deployment-failed:{$this->deployment_uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 56b692cda..415df5831 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -52,16 +52,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "deployment-success:{$this->deployment_uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 507bba28d..635dfdbdc 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -49,16 +49,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "restart-limit-reached:application:{$this->resource->uuid}:count:{$this->restart_count}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/StatusChanged.php b/app/Notifications/Application/StatusChanged.php index 87986435d..ef61b7e6a 100644 --- a/app/Notifications/Application/StatusChanged.php +++ b/app/Notifications/Application/StatusChanged.php @@ -42,16 +42,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "application-status-changed:application:{$this->resource->uuid}:stopped"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; @@ -60,7 +50,6 @@ public function toMail(): MailMessage $mail->view('emails.application-status-changes', [ 'name' => $this->resource_name, 'fqdn' => $fqdn, - 'application_url' => $this->resource_url, 'resource_url' => $this->resource_url, ]); diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 45c6cb2d6..abd115550 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -4,20 +4,13 @@ use App\Exceptions\NonReportableException; use App\Models\Team; -use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Notifications\Notification; use Resend; -use Resend\Exceptions\ErrorException; -use Resend\Exceptions\TransporterException; -use Symfony\Component\Mailer\Mailer; -use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; -use Symfony\Component\Mime\Address; -use Symfony\Component\Mime\Email; class EmailChannel { - public function __construct(private NotificationDeduplicator $deduplicator) {} + public function __construct() {} public function send(SendsEmail $notifiable, Notification $notification): void { @@ -74,11 +67,6 @@ public function send(SendsEmail $notifiable, Notification $notification): void } $mailMessage = $notification->toMail($notifiable); - $renderedMail = (string) $mailMessage->render(); - - if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, $recipients, $mailMessage->subject, $renderedMail)) { - return; - } if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); @@ -87,17 +75,17 @@ public function send(SendsEmail $notifiable, Notification $notification): void 'from' => $from, 'to' => $recipients, 'subject' => $mailMessage->subject, - 'html' => $renderedMail, + 'html' => (string) $mailMessage->render(), ]); } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption ?? '')) { + $encryption = match (strtolower($settings->smtp_encryption)) { 'starttls' => null, 'tls' => 'tls', 'none' => null, default => null, }; - $transport = new EsmtpTransport( + $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( $settings->smtp_host, $settings->smtp_port, $encryption @@ -105,20 +93,20 @@ public function send(SendsEmail $notifiable, Notification $notification): void $transport->setUsername($settings->smtp_username ?? ''); $transport->setPassword($settings->smtp_password ?? ''); - $mailer = new Mailer($transport); + $mailer = new \Symfony\Component\Mailer\Mailer($transport); $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; $fromName = $settings->smtp_from_name ?? 'System'; - $from = new Address($fromEmail, $fromName); - $email = (new Email) + $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); + $email = (new \Symfony\Component\Mime\Email) ->from($from) ->to(...$recipients) ->subject($mailMessage->subject) - ->html($renderedMail); + ->html((string) $mailMessage->render()); $mailer->send($email); } - } catch (ErrorException $e) { + } catch (\Resend\Exceptions\ErrorException $e) { // Map HTTP status codes to user-friendly messages $userMessage = match ($e->getErrorCode()) { 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', @@ -143,13 +131,13 @@ public function send(SendsEmail $notifiable, Notification $notification): void // Don't report expected errors (invalid keys, validation) to Sentry if (in_array($e->getErrorCode(), [403, 401, 400])) { - throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e)); + throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); } - throw new Exception($userMessage, $e->getCode(), $e); - } catch (TransporterException $e) { + throw new \Exception($userMessage, $e->getCode(), $e); + } catch (\Resend\Exceptions\TransporterException $e) { send_internal_notification("Resend Transport Error: {$e->getMessage()}"); - throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); + throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 803db57f3..8ab74a60b 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -3,7 +3,6 @@ namespace App\Notifications\Channels; use App\Models\User; -use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Mail\Message; use Illuminate\Notifications\Notification; @@ -11,8 +10,6 @@ class TransactionalEmailChannel { - public function __construct(private NotificationDeduplicator $deduplicator) {} - public function send(User $notifiable, Notification $notification): void { $settings = instanceSettings(); @@ -30,19 +27,13 @@ public function send(User $notifiable, Notification $notification): void } $this->bootConfigs(); $mailMessage = $notification->toMail($notifiable); - $renderedMail = (string) $mailMessage->render(); - - if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, [$email], $mailMessage->subject, $renderedMail)) { - return; - } - Mail::send( [], [], fn (Message $message) => $message ->to($email) ->subject($mailMessage->subject) - ->html($renderedMail) + ->html((string) $mailMessage->render()) ); } diff --git a/app/Notifications/Container/ContainerRestarted.php b/app/Notifications/Container/ContainerRestarted.php index d51c77cb3..2d7eb58b5 100644 --- a/app/Notifications/Container/ContainerRestarted.php +++ b/app/Notifications/Container/ContainerRestarted.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "container-restarted:server:{$this->server->uuid}:container:{$this->name}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php index 7daba04ca..f518cd2fd 100644 --- a/app/Notifications/Container/ContainerStopped.php +++ b/app/Notifications/Container/ContainerStopped.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "container-stopped:server:{$this->server->uuid}:container:{$this->name}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/CustomEmailNotification.php b/app/Notifications/CustomEmailNotification.php index e3f62e22a..c3c89b30f 100644 --- a/app/Notifications/CustomEmailNotification.php +++ b/app/Notifications/CustomEmailNotification.php @@ -15,19 +15,4 @@ class CustomEmailNotification extends Notification implements ShouldQueue public $tries = 5; public $maxExceptions = 5; - - public function shouldDeduplicate(): bool - { - return true; - } - - public function deduplicateFor(): int - { - return 900; - } - - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return null; - } } diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index 8d9c99603..c2b21b1d5 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -11,8 +11,6 @@ class BackupFailed extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -20,7 +18,6 @@ class BackupFailed extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; } @@ -30,16 +27,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-failed:backup:{$this->backupId}:database:{$this->database->uuid}:output:".hash('sha256', (string) $this->output); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 166a48496..3d2d8ece3 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -11,8 +11,6 @@ class BackupSuccess extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -20,7 +18,6 @@ class BackupSuccess extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -31,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-success:backup:{$this->backupId}:database:{$this->database->uuid}:name:{$this->database_name}:frequency:{$this->frequency}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php index 0da619448..ee24ef17d 100644 --- a/app/Notifications/Database/BackupSuccessWithS3Warning.php +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -11,8 +11,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -22,7 +20,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -37,16 +34,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-s3-warning:backup:{$this->backupId}:database:{$this->database->uuid}:error:".hash('sha256', (string) $this->s3_error); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index 5078ca8e9..bd060112a 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -28,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "scheduled-task-failed:task:{$this->task->uuid}:output:".hash('sha256', $this->output); - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 0231ecf3d..58c959bd8 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -28,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "scheduled-task-success:task:{$this->task->uuid}:output:".hash('sha256', $this->output); - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupFailed.php b/app/Notifications/Server/DockerCleanupFailed.php index ac0eea17d..9cbdeb488 100644 --- a/app/Notifications/Server/DockerCleanupFailed.php +++ b/app/Notifications/Server/DockerCleanupFailed.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "docker-cleanup-failed:server:{$this->server->uuid}:message:".hash('sha256', $this->message); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupSuccess.php b/app/Notifications/Server/DockerCleanupSuccess.php index 7e5ec0bcf..d28f25c6c 100644 --- a/app/Notifications/Server/DockerCleanupSuccess.php +++ b/app/Notifications/Server/DockerCleanupSuccess.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "docker-cleanup-success:server:{$this->server->uuid}:message:".hash('sha256', $this->message); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 8d1817026..4b56f5860 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_disabled'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-force-disabled:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 3db96f995..36dad3c60 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_enabled'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-force-enabled:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index 866d2eb07..bb452b054 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('hetzner_deletion_failed'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "hetzner-deletion-failed:{$this->hetznerServerId}:error:".hash('sha256', $this->errorMessage); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HighDiskUsage.php b/app/Notifications/Server/HighDiskUsage.php index 4007ca805..149d1bbc8 100644 --- a/app/Notifications/Server/HighDiskUsage.php +++ b/app/Notifications/Server/HighDiskUsage.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_disk_usage'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "high-disk-usage:server:{$this->server->uuid}:threshold:{$this->server_disk_usage_notification_threshold}"; - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Reachable.php b/app/Notifications/Server/Reachable.php index b297b7d3d..e64b0af2a 100644 --- a/app/Notifications/Server/Reachable.php +++ b/app/Notifications/Server/Reachable.php @@ -30,16 +30,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_reachable'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-reachable:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 1800; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index d0d5f4875..ba6cd4982 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -24,16 +24,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_patch'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-patch-check:server:{$this->server->uuid}:state:".hash('sha256', json_encode($this->patchData)); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index d6e5ae8aa..c94cc1732 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -38,18 +38,6 @@ private function getUpgradeTarget(array $info): string return $this->formatVersion($info['latest'] ?? 'unknown'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - $serverUuids = $this->servers->pluck('uuid')->sort()->values()->join('|'); - - return 'traefik-version-outdated:servers:'.hash('sha256', $serverUuids); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Unreachable.php b/app/Notifications/Server/Unreachable.php index cd6fd63b6..99742f3b7 100644 --- a/app/Notifications/Server/Unreachable.php +++ b/app/Notifications/Server/Unreachable.php @@ -30,16 +30,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_unreachable'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-unreachable:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): ?MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 72ce136bd..78e1e8be9 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -59,22 +59,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('ssl_certificate_renewal'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - $resourceKeys = $this->resources - ->map(fn ($resource) => data_get($resource, 'uuid') ?? data_get($resource, 'name')) - ->sort() - ->values() - ->join('|'); - - return 'ssl-certificate-renewed:resources:'.hash('sha256', $resourceKeys); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index ea3dfe9c1..bbed22777 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -30,11 +30,6 @@ public function __construct(public ?string $emails = null, public ?string $chann $this->onQueue('high'); } - public function shouldDeduplicate(): bool - { - return false; - } - public function via(object $notifiable): array { if ($this->channel) { diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php index bb5e7f870..ea8462366 100644 --- a/app/Notifications/TransactionalEmails/EmailChangeVerification.php +++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php @@ -25,16 +25,6 @@ public function __construct( $this->onQueue('high'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "email-change-verification:user:{$this->user->id}:email:{$this->newEmail}:code:{$this->verificationCode}"; - } - - public function deduplicateFor(): int - { - return (int) max(1, now()->diffInSeconds($this->expiresAt, false)); - } - public function toMail(): MailMessage { // Use the configured expiry minutes value diff --git a/app/Notifications/TransactionalEmails/InvitationLink.php b/app/Notifications/TransactionalEmails/InvitationLink.php index f3b1e6d67..9bfb54798 100644 --- a/app/Notifications/TransactionalEmails/InvitationLink.php +++ b/app/Notifications/TransactionalEmails/InvitationLink.php @@ -21,16 +21,6 @@ public function __construct(public User $user, public bool $isTransactionalEmail $this->onQueue('high'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "invitation-link:user:{$this->user->id}:email:{$this->user->email}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $invitation = TeamInvitation::whereEmail($this->user->email)->first(); diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php index dc8c0dac7..2f7d70bbf 100644 --- a/app/Notifications/TransactionalEmails/Test.php +++ b/app/Notifications/TransactionalEmails/Test.php @@ -15,11 +15,6 @@ public function __construct(public string $emails, public bool $isTransactionalE $this->onQueue('high'); } - public function shouldDeduplicate(): bool - { - return false; - } - public function via(): array { return [EmailChannel::class]; diff --git a/app/Services/NotificationDeduplicator.php b/app/Services/NotificationDeduplicator.php deleted file mode 100644 index d018dd0a5..000000000 --- a/app/Services/NotificationDeduplicator.php +++ /dev/null @@ -1,95 +0,0 @@ - $recipients - */ - public function shouldSend(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject = null, ?string $body = null): bool - { - if (method_exists($notification, 'shouldDeduplicate') && ! $notification->shouldDeduplicate()) { - return true; - } - - $ttl = method_exists($notification, 'deduplicateFor') - ? $notification->deduplicateFor() - : self::DEFAULT_TTL; - - if ($ttl <= 0) { - return true; - } - - return Cache::add( - $this->cacheKey($notifiable, $notification, $channel, $recipients, $subject, $body), - true, - $ttl, - ); - } - - /** - * @param arrayBody
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeFalse(); -}); - -it('allows different recipients and content through the default fingerprint', function () { - $notification = new DedupeTestNotification; - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Other body
'))->toBeTrue(); -}); - -it('uses semantic keys instead of rendered content when provided', function () { - $notification = new DedupeTestNotification(semanticKey: 'event:123'); - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', 'Other body
'))->toBeFalse() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Other subject', 'Other body
'))->toBeTrue(); -}); - -it('allows notifications to opt out of deduplication', function () { - $notification = new DedupeTestNotification(deduplicate: false); - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue(); -}); From bcadcc920083e5383869ce9bc3ca78ec927e3dcd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:47:38 +0200 Subject: [PATCH 117/125] docs(readme): serve sponsor images from Coollabs CDN --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 91458b703..ee4028d6a 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ ### Big Sponsors ### Small Sponsors -
+
@@ -113,39 +113,39 @@ ### Small Sponsors
-
-
+
+
-
-
+
-
+
-
-
-
-
-
+
+
+
-
-
+
-
+
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
From dd10a90d8c82d2eee20a772bea28df7d60310ed4 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:48:06 +0200
Subject: [PATCH 118/125] fix(meta): update social preview image URL
---
resources/views/layouts/base.blade.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index be7b928ab..553248b60 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -22,13 +22,13 @@
-
+
-
+
@use('App\Models\InstanceSettings')
@php
From e4f925ebbfc52d75d9921d22781054576c7783ea Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:49:59 +0200
Subject: [PATCH 119/125] fix(meta): serve releases metadata from Coollabs CDN
---
config/constants.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/config/constants.php b/config/constants.php
index b9e3d600f..bf053fde3 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -16,7 +16,7 @@
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
- 'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'),
+ 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'),
],
'urls' => [
From d3fbb32c527bf880244330c3ce655ad8a672e935 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Jul 2026 14:29:11 +0200
Subject: [PATCH 120/125] feat(cdn): sync release metadata through BunnyCDN
Replace the legacy sync:bunny flags with an interactive CDN sync flow for service templates and release metadata. Serve official service templates from the Coollabs CDN, update version metadata, and remove obsolete helper scripts.
---
app/Console/Commands/SyncBunny.php | 321 +++++++++++++++---
.../Concerns/SummarizesDiffText.php | 2 +-
config/constants.php | 4 +-
other/nightly/versions.json | 4 +-
scripts/conductor-setup.sh | 97 ------
scripts/sync_volume.sh | 57 ----
tests/Feature/PullChangelogTest.php | 2 +-
tests/Feature/SyncBunnyTest.php | 232 ++++++++++---
.../ApplicationConfigurationSnapshotTest.php | 8 +-
versions.json | 4 +-
10 files changed, 475 insertions(+), 256 deletions(-)
delete mode 100755 scripts/conductor-setup.sh
delete mode 100644 scripts/sync_volume.sh
diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php
index 3f3e213fd..55acf3828 100644
--- a/app/Console/Commands/SyncBunny.php
+++ b/app/Console/Commands/SyncBunny.php
@@ -5,9 +5,12 @@
use Illuminate\Console\Command;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Pool;
+use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\multiselect;
+use function Laravel\Prompts\select;
class SyncBunny extends Command
{
@@ -16,7 +19,7 @@ class SyncBunny extends Command
*
* @var string
*/
- protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
+ protected $signature = 'sync:bunny {--bunny}';
/**
* The console command description.
@@ -25,15 +28,234 @@ class SyncBunny extends Command
*/
protected $description = 'Sync files to BunnyCDN';
+ protected function removeTemporaryDirectory(string $tmpDir): void
+ {
+ $temporaryRoot = realpath(sys_get_temp_dir());
+ $temporaryDirectory = realpath($tmpDir);
+
+ if ($temporaryRoot === false || $temporaryDirectory === false) {
+ return;
+ }
+
+ $expectedPrefix = rtrim($temporaryRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'coollabs-cdn-';
+ if (! str_starts_with($temporaryDirectory, $expectedPrefix)) {
+ return;
+ }
+
+ File::deleteDirectory($temporaryDirectory);
+ }
+
+ /**
+ * Fetch GitHub releases and sync to GitHub repository
+ */
+ private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool
+ {
+ $this->info('Fetching releases from GitHub...');
+ try {
+ $response = Http::timeout(30)
+ ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
+ 'per_page' => 30, // Fetch more releases for better changelog
+ ]);
+
+ if (! $response->successful()) {
+ $this->error('Failed to fetch releases from GitHub: '.$response->status());
+
+ return false;
+ }
+
+ $releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-');
+ if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
+ $this->error('Failed to create temporary releases.json.');
+
+ return false;
+ }
+
+ $files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
+
+ try {
+ return $this->syncFilesToGitHubRepo($files, $nightly);
+ } finally {
+ @unlink($releasesFile);
+ }
+ } catch (\Throwable $e) {
+ $this->error('Error syncing releases: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
+ /**
+ * Sync install.sh, docker-compose, and env files to GitHub repository via PR
+ */
+ private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
+ {
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $this->info("Syncing $envLabel files to GitHub repository...");
+ try {
+ $timestamp = time();
+ $tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp;
+ $branchName = 'update-files-'.$timestamp;
+
+ // Clone the repository
+ $this->info('Cloning coollabs-cdn repository...');
+ $output = [];
+ exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to clone repository: '.implode("\n", $output));
+
+ return false;
+ }
+
+ // Create feature branch
+ $this->info('Creating feature branch...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to create branch: '.implode("\n", $output));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ // Copy each file to its target path in the CDN repo
+ $copiedFiles = [];
+ foreach ($files as $sourceFile => $targetPath) {
+ if (! file_exists($sourceFile)) {
+ $this->warn("Source file not found, skipping: $sourceFile");
+
+ continue;
+ }
+
+ $destPath = "$tmpDir/$targetPath";
+ $destDir = dirname($destPath);
+
+ if (! is_dir($destDir)) {
+ if (! mkdir($destDir, 0755, true)) {
+ $this->error("Failed to create directory: $destDir");
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+ }
+
+ if (copy($sourceFile, $destPath) === false) {
+ $this->error("Failed to copy $sourceFile to $destPath");
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ $copiedFiles[] = $targetPath;
+ $this->info("Copied: $targetPath");
+ }
+
+ if (empty($copiedFiles)) {
+ $this->warn('No files were copied. Nothing to commit.');
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return true;
+ }
+
+ // Stage all copied files
+ $this->info('Staging changes...');
+ $output = [];
+ $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
+ exec($stageCmd, $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to stage changes: '.implode("\n", $output));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ // Check for changes
+ $this->info('Checking for changes...');
+ $changedFiles = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to check changed files: '.implode("\n", $changedFiles));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ $changedFiles = array_values(array_filter($changedFiles));
+ if (empty($changedFiles)) {
+ $this->info('All files are already up to date. No changes to commit.');
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return true;
+ }
+
+ // Commit changes
+ $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to commit changes: '.implode("\n", $output));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ // Push to remote
+ $this->info('Pushing branch to remote...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to push branch: '.implode("\n", $output));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ // Create pull request
+ $this->info('Creating pull request...');
+ $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
+ $fileList = implode("\n- ", $changedFiles);
+ $prBody = "Automated update of $envLabel files:\n- $fileList";
+ $prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
+ $output = [];
+ exec($prCommand, $output, $returnCode);
+
+ // Clean up
+ $this->removeTemporaryDirectory($tmpDir);
+
+ if ($returnCode !== 0) {
+ $this->error('Failed to create PR: '.implode("\n", $output));
+
+ return false;
+ }
+
+ $this->info('Pull request created successfully!');
+ if (! empty($output)) {
+ $this->info('PR URL: '.implode("\n", $output));
+ }
+ $this->info('Files synced: '.count($changedFiles));
+
+ return true;
+ } catch (\Throwable $e) {
+ $this->error('Error syncing files to GitHub: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
/**
* Execute the console command.
*/
public function handle()
{
$that = $this;
- $only_template = $this->option('templates');
- $only_version = $this->option('release');
- $nightly = $this->option('nightly');
+ $only_bunny = $this->option('bunny');
+ $nightly = select(
+ label: 'Which environment would you like to sync?',
+ options: [
+ 'production' => 'Production',
+ 'nightly' => 'Nightly',
+ ],
+ default: 'production',
+ ) === 'nightly';
$bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify';
$bunny_cdn_storage_name = 'coolcdn';
@@ -55,6 +277,7 @@ public function handle()
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
$upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
$production_env_location = "$parent_dir/.env.production";
+ $service_template_location = "$parent_dir/templates/$service_template";
$versions_location = "$parent_dir/$versions";
PendingRequest::macro('storage', function ($fileName) use ($that) {
@@ -93,7 +316,7 @@ public function handle()
$install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions";
}
- if (! $only_template && ! $only_version) {
+ if ($only_bunny) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("About to sync $envLabel files to BunnyCDN.");
$this->newLine();
@@ -108,7 +331,7 @@ public function handle()
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
];
- $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time();
+ $diffTmpDir = sys_get_temp_dir().'/coollabs-cdn-diff-'.time();
@mkdir($diffTmpDir, 0755, true);
$hasChanges = false;
@@ -151,7 +374,7 @@ public function handle()
}
}
- exec('rm -rf '.escapeshellarg($diffTmpDir));
+ $this->removeTemporaryDirectory($diffTmpDir);
if (! $hasChanges) {
$this->newLine();
@@ -167,49 +390,55 @@ public function handle()
return;
}
}
- if ($only_template) {
- $this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.');
- $confirmed = confirm('Are you sure you want to sync?');
- if (! $confirmed) {
- return;
- }
- Http::pool(fn (Pool $pool) => [
- $pool->storage(fileName: "$parent_dir/templates/$service_template")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$service_template"),
- $pool->purge("$bunny_cdn/$bunny_cdn_path/$service_template"),
- ]);
- $this->info('Service template uploaded & purged...');
+ if (! $only_bunny) {
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository.");
- return;
- } elseif ($only_version) {
if ($nightly) {
- $this->info('About to sync NIGHTLY versions.json to BunnyCDN.');
+ $files = [
+ $versions_location => 'json/coolify/nightly/versions.json',
+ $compose_file_location => 'json/coolify/nightly/docker-compose.yml',
+ $compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml',
+ $production_env_location => 'json/coolify/nightly/.env.production',
+ $install_script_location => 'json/coolify/nightly/install.sh',
+ $upgrade_script_location => 'json/coolify/nightly/upgrade.sh',
+ $upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh',
+ $service_template_location => 'json/coolify/nightly/service-templates-latest.json',
+ ];
} else {
- $this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
- }
- $file = file_get_contents($versions_location);
- $json = json_decode($file, true);
- $actual_version = data_get($json, 'coolify.v4.version');
-
- $this->info("Version: {$actual_version}");
- $this->info('This will:');
- $this->info(' 1. Sync versions.json to BunnyCDN');
- $this->newLine();
-
- $confirmed = confirm('Are you sure you want to proceed?');
- if (! $confirmed) {
- return;
+ $files = [
+ $versions_location => 'json/coolify/versions.json',
+ $compose_file_location => 'json/coolify/docker-compose.yml',
+ $compose_file_prod_location => 'json/coolify/docker-compose.prod.yml',
+ $production_env_location => 'json/coolify/.env.production',
+ $install_script_location => 'json/coolify/install.sh',
+ $upgrade_script_location => 'json/coolify/upgrade.sh',
+ $upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh',
+ $service_template_location => 'json/coolify/service-templates-latest.json',
+ ];
}
- $this->info('Syncing versions.json to BunnyCDN...');
- Http::pool(fn (Pool $pool) => [
- $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
- $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
- ]);
- $this->info('✓ versions.json uploaded & purged to BunnyCDN');
- $this->newLine();
+ $releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
+ $options = [$releasesTarget, ...array_values($files)];
+ $selectedFiles = multiselect(
+ label: 'Which files would you like to sync?',
+ options: $options,
+ default: $options,
+ required: true,
+ scroll: count($options),
+ );
- $this->info('=== Summary ===');
- $this->info('BunnyCDN sync: ✓ Complete');
+ $includeReleases = in_array($releasesTarget, $selectedFiles, true);
+ $files = array_filter(
+ $files,
+ fn (string $targetPath) => in_array($targetPath, $selectedFiles, true),
+ );
+
+ if ($includeReleases) {
+ $this->syncReleasesToGitHubRepo($files, $nightly);
+ } else {
+ $this->syncFilesToGitHubRepo($files, $nightly);
+ }
return;
}
@@ -231,10 +460,6 @@ public function handle()
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
]);
$this->info('All files uploaded & purged to BunnyCDN.');
- $this->newLine();
-
- $this->info('=== Summary ===');
- $this->info('BunnyCDN sync: Complete');
} catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage());
}
diff --git a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php
index 6960a8f1b..8eedf0920 100644
--- a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php
+++ b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php
@@ -9,7 +9,7 @@ trait SummarizesDiffText
* worth expanding. Kept as one constant so the snapshot summary and the
* differ's expand decision never drift apart.
*/
- private const SINGLE_LINE_LIMIT = 120;
+ private const SINGLE_LINE_LIMIT = 40;
/**
* Returns the value only when it is worth expanding (multi-line or longer
diff --git a/config/constants.php b/config/constants.php
index bf053fde3..290ce3f95 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -25,9 +25,7 @@
],
'services' => [
- // Temporary disabled until cache is implemented
- // 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json',
- 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json',
+ 'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json',
'file_name' => 'service-templates-latest.json',
],
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 751db0754..9c9a405aa 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.2.0"
+ "version": "4.1.2"
},
"nightly": {
- "version": "4.2.1"
+ "version": "4.2.0"
},
"helper": {
"version": "1.0.14"
diff --git a/scripts/conductor-setup.sh b/scripts/conductor-setup.sh
deleted file mode 100755
index a88b457fb..000000000
--- a/scripts/conductor-setup.sh
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/bin/bash
-set -e
-
-# Validate CONDUCTOR_ROOT_PATH is set and valid before any operations
-if [ -z "$CONDUCTOR_ROOT_PATH" ]; then
- echo "ERROR: CONDUCTOR_ROOT_PATH environment variable is not set"
- echo "This script must be run by Conductor with CONDUCTOR_ROOT_PATH set to the main repository path"
- exit 1
-fi
-
-if [ ! -d "$CONDUCTOR_ROOT_PATH" ]; then
- echo "ERROR: CONDUCTOR_ROOT_PATH ($CONDUCTOR_ROOT_PATH) is not a valid directory"
- exit 1
-fi
-
-# Copy .env file
-cp "$CONDUCTOR_ROOT_PATH/.env" .env
-
-# Setup shared dependencies via symlinks to main repo
-echo "Setting up shared node_modules and vendor directories..."
-
-# Ensure main repo has the directories
-mkdir -p "$CONDUCTOR_ROOT_PATH/node_modules"
-mkdir -p "$CONDUCTOR_ROOT_PATH/vendor"
-
-# Get current worktree path
-WORKTREE_PATH=$(pwd)
-
-# Safety check 1: ensure WORKTREE_PATH is valid
-if [ -z "$WORKTREE_PATH" ]; then
- echo "ERROR: WORKTREE_PATH is empty"
- exit 1
-fi
-
-# Safety check 2: CRITICAL FIRST - blacklist system directories
-# This check runs BEFORE the positive check to prevent dangerous operations
-# even if someone misconfigures CONDUCTOR_ROOT_PATH
-case "$WORKTREE_PATH" in
- /|/bin|/sbin|/usr|/usr/*|/etc|/etc/*|/var|/var/*|/System|/System/*|/Library|/Library/*|/Applications|/Applications/*|"$HOME")
- echo "ERROR: WORKTREE_PATH ($WORKTREE_PATH) is in a dangerous system location"
- exit 1
- ;;
-esac
-
-# Safety check 3: positive check - verify we're under CONDUCTOR_ROOT_PATH
-case "$WORKTREE_PATH" in
- "$CONDUCTOR_ROOT_PATH"|"$CONDUCTOR_ROOT_PATH"/.conductor/*)
- # Valid: either main repo or under .conductor/
- ;;
- *)
- echo "ERROR: WORKTREE_PATH ($WORKTREE_PATH) is not under CONDUCTOR_ROOT_PATH ($CONDUCTOR_ROOT_PATH)"
- exit 1
- ;;
-esac
-
-# Safety check 4: verify we're in a git repository
-if [ ! -f ".git" ] && [ ! -d ".git" ]; then
- echo "ERROR: Not in a git repository"
- exit 1
-fi
-
-# Remove existing directories/symlinks if they exist
-# For symlinks: use 'rm' without -r to remove the symlink itself (not following it)
-# For directories: use 'rm -rf' to remove the directory and contents
-if [ -L "node_modules" ]; then
- # It's a symlink - remove it without following (no -r flag)
- rm "$WORKTREE_PATH/node_modules"
-elif [ -e "node_modules" ]; then
- # It's a regular directory or file - safe to use -rf
- rm -rf "$WORKTREE_PATH/node_modules"
-fi
-
-if [ -L "vendor" ]; then
- # It's a symlink - remove it without following (no -r flag)
- rm "$WORKTREE_PATH/vendor"
-elif [ -e "vendor" ]; then
- # It's a regular directory or file - safe to use -rf
- rm -rf "$WORKTREE_PATH/vendor"
-fi
-
-# Calculate relative path from worktree to main repo
-# Use bash-native approach: try realpath first (GNU coreutils), fallback to perl
-if command -v realpath &> /dev/null && realpath --relative-to / / &> /dev/null 2>&1; then
- # GNU coreutils realpath with --relative-to support
- RELATIVE_PATH=$(realpath --relative-to="$WORKTREE_PATH" "$CONDUCTOR_ROOT_PATH")
-else
- # Fallback: use perl which is standard on macOS and most Unix systems
- RELATIVE_PATH=$(perl -e 'use File::Spec; print File::Spec->abs2rel($ARGV[0], $ARGV[1])' "$CONDUCTOR_ROOT_PATH" "$WORKTREE_PATH")
-fi
-
-# Create symlinks to main repo's node_modules and vendor
-ln -sf "$RELATIVE_PATH/node_modules" node_modules
-ln -sf "$RELATIVE_PATH/vendor" vendor
-
-echo "✓ Shared dependencies linked successfully"
-echo " node_modules -> $RELATIVE_PATH/node_modules"
-echo " vendor -> $RELATIVE_PATH/vendor"
\ No newline at end of file
diff --git a/scripts/sync_volume.sh b/scripts/sync_volume.sh
deleted file mode 100644
index 43631fdf7..000000000
--- a/scripts/sync_volume.sh
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/bin/bash
-# Sync docker volumes between two servers
-
-VERSION="1.0.0"
-SOURCE=$1
-DESTINATION=$2
-set -e
-if [ -z "$SOURCE" ]; then
- echo "Source server is not specified."
- exit 1
-fi
-if [ -z "$DESTINATION" ]; then
- echo "Destination server is not specified."
- exit 1
-fi
-
-SOURCE_USER=$(echo $SOURCE | cut -d@ -f1)
-SOURCE_SERVER=$(echo $SOURCE | cut -d: -f1 | cut -d@ -f2)
-SOURCE_PORT=$(echo $SOURCE | cut -d: -f2 | cut -d/ -f1)
-SOURCE_VOLUME_NAME=$(echo $SOURCE | cut -d/ -f2)
-
-if ! [[ "$SOURCE_PORT" =~ ^[0-9]+$ ]]; then
- echo "Invalid source port: $SOURCE_PORT"
- exit 1
-fi
-
-DESTINATION_USER=$(echo $DESTINATION | cut -d@ -f1)
-DESTINATION_SERVER=$(echo $DESTINATION | cut -d: -f1 | cut -d@ -f2)
-DESTINATION_PORT=$(echo $DESTINATION | cut -d: -f2 | cut -d/ -f1)
-DESTINATION_VOLUME_NAME=$(echo $DESTINATION | cut -d/ -f2)
-
-if ! [[ "$DESTINATION_PORT" =~ ^[0-9]+$ ]]; then
- echo "Invalid destination port: $DESTINATION_PORT"
- exit 1
-fi
-
-echo "Generating backup file to ./$SOURCE_VOLUME_NAME.tgz"
-ssh -p $SOURCE_PORT $SOURCE_USER@$SOURCE_SERVER "docker run -v $SOURCE_VOLUME_NAME:/volume --rm --log-driver none loomchild/volume-backup backup -c pigz -v" >./$SOURCE_VOLUME_NAME.tgz
-echo ""
-if [ -f "./$SOURCE_VOLUME_NAME.tgz" ]; then
- echo "Uploading backup file to $DESTINATION_SERVER:~/$DESTINATION_VOLUME_NAME.tgz"
- scp -P $DESTINATION_PORT ./$SOURCE_VOLUME_NAME.tgz $DESTINATION_USER@$DESTINATION_SERVER:~/$DESTINATION_VOLUME_NAME.tgz
- echo ""
- echo "Restoring backup file on remote ($DESTINATION_SERVER:/~/$DESTINATION_VOLUME_NAME.tgz)"
- ssh -p $DESTINATION_PORT $DESTINATION_USER@$DESTINATION_SERVER "docker run -i -v $DESTINATION_VOLUME_NAME:/volume --log-driver none --rm loomchild/volume-backup restore -c pigz -vf < ~/$DESTINATION_VOLUME_NAME.tgz"
- echo ""
- echo "Deleting backup file on remote ($DESTINATION_SERVER:/~/$DESTINATION_VOLUME_NAME.tgz)"
- ssh -p $DESTINATION_PORT $DESTINATION_USER@$DESTINATION_SERVER "rm ~/$DESTINATION_VOLUME_NAME.tgz"
-
- echo ""
- echo "Local file ./$SOURCE_VOLUME_NAME.tgz is not deleted."
-
- echo ""
- echo "WARNING: If you are copying a database volume, you need to set the right users/passwords on the destination service's environment variables."
- echo "Why? Because we are copying the volume as-is, so the database credentials will bethe same as on the source volume."
-fi
-
diff --git a/tests/Feature/PullChangelogTest.php b/tests/Feature/PullChangelogTest.php
index 145638812..7793b0b77 100644
--- a/tests/Feature/PullChangelogTest.php
+++ b/tests/Feature/PullChangelogTest.php
@@ -34,7 +34,7 @@ function fakeReleasesPayload(): array
test('releases_url config defaults to the GitHub raw source', function () {
expect(config('constants.coolify.releases_url'))
- ->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json');
+ ->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json');
});
test('PullChangelog fetches from the configured releases_url and writes the changelog', function () {
diff --git a/tests/Feature/SyncBunnyTest.php b/tests/Feature/SyncBunnyTest.php
index ca3091841..9f4badad3 100644
--- a/tests/Feature/SyncBunnyTest.php
+++ b/tests/Feature/SyncBunnyTest.php
@@ -1,63 +1,107 @@
> "$SYNC_BUNNY_TEST_LOG"
-exit 1
-SH);
+ file_put_contents("{$binDir}/{$name}", $contents);
chmod("{$binDir}/{$name}", 0755);
}
-it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () {
- Http::fake([
- 'storage.bunnycdn.com/*' => Http::response([], 201),
- 'api.bunny.net/purge*' => Http::response([], 200),
- ]);
+it('only exposes the BunnyCDN legacy sync option', function () {
+ $definition = Artisan::all()['sync:bunny']->getDefinition();
- $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
- $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
-
- mkdir($binDir, 0755, true);
- createSyncBunnyFailingBinary($binDir, 'gh');
- createSyncBunnyFailingBinary($binDir, 'git');
-
- $originalPath = getenv('PATH') ?: '';
- putenv("PATH={$binDir}:{$originalPath}");
- putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
-
- try {
- $this->artisan('sync:bunny --release --nightly')
- ->expectsConfirmation('Are you sure you want to proceed?', 'yes')
- ->expectsOutputToContain('BunnyCDN sync: ✓ Complete')
- ->doesntExpectOutputToContain('GitHub PR')
- ->assertExitCode(0);
- } finally {
- putenv("PATH={$originalPath}");
- putenv('SYNC_BUNNY_TEST_LOG');
- }
-
- expect(file_exists($logFile))->toBeFalse();
-
- Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json');
- Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
- && $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
+ expect($definition->hasOption('bunny'))->toBeTrue()
+ ->and($definition->hasOption('github-releases'))->toBeFalse()
+ ->and($definition->hasOption('release'))->toBeFalse()
+ ->and($definition->hasOption('nightly'))->toBeFalse()
+ ->and($definition->hasOption('templates'))->toBeFalse();
});
-it('syncs postgres upgrade script to BunnyCDN during full sync', function () {
+it('loads service templates from the Coollabs CDN', function () {
+ expect(config('constants.services.official'))
+ ->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json');
+});
+
+it('only removes validated Coolify CDN temporary directories', function () {
+ $command = new class extends SyncBunny
+ {
+ public function removeDirectory(string $path): void
+ {
+ $this->removeTemporaryDirectory($path);
+ }
+ };
+
+ $invalidDirectory = sys_get_temp_dir().'/unrelated-directory-'.uniqid();
+ $validDirectory = sys_get_temp_dir().'/coollabs-cdn-files-'.uniqid();
+ mkdir($invalidDirectory);
+ mkdir($validDirectory);
+
+ $command->removeDirectory('');
+ $command->removeDirectory($invalidDirectory);
+ $command->removeDirectory($validDirectory);
+
+ expect($invalidDirectory)->toBeDirectory()
+ ->and($validDirectory)->not->toBeDirectory();
+
+ rmdir($invalidDirectory);
+});
+
+it('syncs full files to BunnyCDN only when explicitly requested', function () {
Http::fake([
'https://cdn.coollabs.io/coolify/*' => Http::response('', 404),
'https://storage.bunnycdn.com/*' => Http::response([], 201),
'https://api.bunny.net/purge*' => Http::response([], 200),
]);
- $this->artisan('sync:bunny')
- ->expectsConfirmation('Are you sure you want to sync?', 'yes')
- ->expectsOutputToContain('BunnyCDN sync: Complete')
- ->assertExitCode(0);
+ $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
+ $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
+
+ mkdir($binDir, 0755, true);
+
+ createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH'
+#!/bin/sh
+printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
+if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then
+ mkdir -p "$4/scripts"
+fi
+exit 0
+SH);
+
+ createFakeSyncBunnyBinary($binDir, 'git', <<<'SH'
+#!/bin/sh
+printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
+if [ "$1" = "status" ]; then
+ printf 'M scripts/upgrade-postgres.sh\n'
+fi
+exit 0
+SH);
+
+ $originalPath = getenv('PATH') ?: '';
+ putenv("PATH={$binDir}:{$originalPath}");
+ putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
+
+ try {
+ $this->artisan('sync:bunny --bunny')
+ ->expectsChoice('Which environment would you like to sync?', 'production', [
+ 'production' => 'Production',
+ 'nightly' => 'Nightly',
+ ])
+ ->expectsConfirmation('Are you sure you want to sync?', 'yes')
+ ->assertExitCode(0);
+ } finally {
+ putenv("PATH={$originalPath}");
+ putenv('SYNC_BUNNY_TEST_LOG');
+ }
+
+ $log = file_exists($logFile) ? file_get_contents($logFile) : '';
+
+ expect($log)
+ ->not->toContain('gh repo clone')
+ ->not->toContain('gh pr create')
+ ->not->toContain('coollabsio/coolify-cdn');
Http::assertSent(fn ($request) => $request->method() === 'PUT'
&& $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify/upgrade-postgres.sh');
@@ -65,3 +109,105 @@ function createSyncBunnyFailingBinary(string $binDir, string $name): void
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
&& $request['url'] === 'https://cdn.coollabs.io/coolify/upgrade-postgres.sh');
});
+
+it('selects the environment and release files to sync to GitHub', function (string $targetDirectory, string $environment, array $selectedBasenames) {
+ Http::fake([
+ 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200),
+ ]);
+
+ $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
+ $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
+
+ mkdir($binDir, 0755, true);
+
+ createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH'
+#!/bin/sh
+printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
+if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then
+ mkdir -p "$4"
+fi
+exit 0
+SH);
+
+ createFakeSyncBunnyBinary($binDir, 'git', <<<'SH'
+#!/bin/sh
+printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
+if [ "$1" = "status" ]; then
+ printf 'M json/releases.json\n'
+fi
+if [ "$1" = "diff" ]; then
+ if [ -f json/coolify/nightly/releases.json ]; then
+ printf 'json/coolify/nightly/releases.json\n'
+ else
+ printf 'json/coolify/releases.json\n'
+ fi
+fi
+exit 0
+SH);
+
+ $originalPath = getenv('PATH') ?: '';
+ putenv("PATH={$binDir}:{$originalPath}");
+ putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
+
+ $allBasenames = [
+ 'releases.json',
+ 'versions.json',
+ 'docker-compose.yml',
+ 'docker-compose.prod.yml',
+ '.env.production',
+ 'install.sh',
+ 'upgrade.sh',
+ 'upgrade-postgres.sh',
+ 'service-templates-latest.json',
+ ];
+ $allTargets = array_map(fn (string $file) => "$targetDirectory/$file", $allBasenames);
+ $selectedTargets = array_map(fn (string $file) => "$targetDirectory/$file", $selectedBasenames);
+
+ try {
+ $this->artisan('sync:bunny')
+ ->expectsChoice('Which environment would you like to sync?', $environment, [
+ 'production' => 'Production',
+ 'nightly' => 'Nightly',
+ ])
+ ->expectsChoice('Which files would you like to sync?', $selectedTargets, $allTargets)
+ ->assertExitCode(0);
+ } finally {
+ putenv("PATH={$originalPath}");
+ putenv('SYNC_BUNNY_TEST_LOG');
+ }
+
+ $log = file_get_contents($logFile);
+
+ expect($log)
+ ->toContain('gh pr create --repo coollabsio/coollabs-cdn')
+ ->not->toContain('coollabsio/coolify-cdn');
+
+ foreach ($selectedTargets as $selectedTarget) {
+ expect($log)->toContain($selectedTarget);
+ }
+
+ foreach (array_diff($allTargets, $selectedTargets) as $unselectedTarget) {
+ expect($log)->not->toContain($unselectedTarget);
+ }
+
+ $pullRequestCommand = substr($log, strrpos($log, 'gh pr create'));
+
+ expect($pullRequestCommand)
+ ->toContain("$targetDirectory/releases.json")
+ ->not->toContain("$targetDirectory/versions.json");
+
+ Http::assertSentCount(1);
+})->with([
+ 'select production files' => ['json/coolify', 'production', ['releases.json', 'versions.json']],
+ 'select nightly with all files selected by default' => ['json/coolify/nightly', 'nightly', [
+ 'releases.json',
+ 'versions.json',
+ 'docker-compose.yml',
+ 'docker-compose.prod.yml',
+ '.env.production',
+ 'install.sh',
+ 'upgrade.sh',
+ 'upgrade-postgres.sh',
+ 'service-templates-latest.json',
+ ]],
+]);
diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
index 20b7c0adc..c6c823633 100644
--- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
+++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
@@ -66,12 +66,16 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
- $application->update(['fqdn' => 'https://new.example.com']);
+ $domains = 'https://new.example.com,https://another.example.com';
+ $application->update(['fqdn' => $domains]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
+ $change = collect($diff->changes())->firstWhere('label', 'Domains');
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
- ->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
+ ->and($change)->not->toBeNull()
+ ->and($change['expandable'])->toBeTrue()
+ ->and($change['new_full_value'])->toBe($domains);
});
it('detects environment variable value changes without exposing secret values', function () {
diff --git a/versions.json b/versions.json
index 751db0754..9c9a405aa 100644
--- a/versions.json
+++ b/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.2.0"
+ "version": "4.1.2"
},
"nightly": {
- "version": "4.2.1"
+ "version": "4.2.0"
},
"helper": {
"version": "1.0.14"
From e01b8a057e6437cf2a2bafe61db67943acc7bb39 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Sat, 11 Jul 2026 21:35:10 +0200
Subject: [PATCH 121/125] fix(servers): retain cloud instances awaiting IPs
Persist DigitalOcean, Hetzner, and Vultr servers before public IP
assignment, then backfill placeholder addresses from provider state.
Treat partial Sentinel snapshots as non-authoritative and document the
destinations API with OpenAPI schemas.
---
.../Api/DestinationsController.php | 117 +++++++
.../Controllers/Api/SentinelController.php | 15 +-
app/Jobs/PushServerUpdateJob.php | 12 +
app/Livewire/Server/New/ByDigitalOcean.php | 42 +--
app/Livewire/Server/New/ByHetzner.php | 11 +-
app/Livewire/Server/New/ByVultr.php | 21 +-
app/Livewire/Server/Show.php | 5 +
app/Models/Server.php | 39 ++-
app/Models/StandaloneDocker.php | 19 ++
...4_add_uuid_to_cloud_init_scripts_table.php | 2 +-
openapi.json | 291 ++++++++++++++++++
openapi.yaml | 190 ++++++++++++
.../views/livewire/server/navbar.blade.php | 2 +-
tests/Feature/Api/DestinationsApiTest.php | 20 ++
.../Authorization/ApiAuthorizationTest.php | 5 +-
.../DigitalOceanServerCreationTest.php | 86 ++++++
tests/Feature/Mcp/McpEndpointTest.php | 11 +
tests/Feature/MoveResourceApiTest.php | 5 +-
.../PushServerUpdateJobLastOnlineTest.php | 109 +++++++
.../Feature/SentinelPushDeduplicationTest.php | 21 ++
.../Server/HetznerServerPlaceholderIpTest.php | 118 +++++++
tests/Feature/ServiceExtraFieldsTest.php | 4 +-
tests/Feature/VultrServerCreationTest.php | 44 ++-
...pplicationDeploymentRailpackConfigTest.php | 8 +-
.../ApplicationConfigurationSnapshotTest.php | 2 +
tests/Unit/DestinationsOpenApiTest.php | 13 +
tests/Unit/DigitalOceanServerStateTest.php | 48 ++-
tests/Unit/NavbarThemeSwitcherTest.php | 7 +-
tests/Unit/ServerPlaceholderIpTest.php | 67 ++++
29 files changed, 1275 insertions(+), 59 deletions(-)
create mode 100644 tests/Feature/Server/HetznerServerPlaceholderIpTest.php
create mode 100644 tests/Unit/DestinationsOpenApiTest.php
create mode 100644 tests/Unit/ServerPlaceholderIpTest.php
diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php
index f58e2ee71..a745ea5d2 100644
--- a/app/Http/Controllers/Api/DestinationsController.php
+++ b/app/Http/Controllers/Api/DestinationsController.php
@@ -10,6 +10,7 @@
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use OpenApi\Attributes as OA;
class DestinationsController extends Controller
{
@@ -59,6 +60,22 @@ private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDo
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
+ #[OA\Get(
+ summary: 'List destinations',
+ description: 'List all Docker network destinations for the authenticated team.',
+ path: '/destinations',
+ operationId: 'list-destinations',
+ security: [['bearerAuth' => []]],
+ tags: ['Destinations'],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Destinations for the authenticated team.',
+ content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ ],
+ )]
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -74,6 +91,26 @@ public function index(Request $request): JsonResponse
);
}
+ #[OA\Get(
+ summary: 'List destinations by server',
+ description: 'List Docker network destinations attached to a server owned by the authenticated team.',
+ path: '/servers/{server_uuid}/destinations',
+ operationId: 'list-server-destinations',
+ security: [['bearerAuth' => []]],
+ tags: ['Destinations'],
+ parameters: [
+ new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Destinations attached to the server.',
+ content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ],
+ )]
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -89,6 +126,26 @@ public function index_by_server(Request $request, string $server_uuid): JsonResp
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
+ #[OA\Get(
+ summary: 'Get destination',
+ description: 'Get a Docker network destination by UUID.',
+ path: '/destinations/{uuid}',
+ operationId: 'get-destination-by-uuid',
+ security: [['bearerAuth' => []]],
+ tags: ['Destinations'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Destination details.',
+ content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ],
+ )]
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -100,6 +157,40 @@ public function show(Request $request, string $uuid): JsonResponse
return response()->json($this->transform($destination));
}
+ #[OA\Post(
+ summary: 'Create destination',
+ description: 'Create a Docker network destination on a server owned by the authenticated team.',
+ path: '/servers/{server_uuid}/destinations',
+ operationId: 'create-server-destination',
+ security: [['bearerAuth' => []]],
+ tags: ['Destinations'],
+ parameters: [
+ new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ requestBody: new OA\RequestBody(
+ required: true,
+ content: new OA\JsonContent(
+ required: ['network'],
+ properties: [
+ new OA\Property(property: 'name', type: 'string', maxLength: 255),
+ new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'),
+ new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
+ ],
+ type: 'object',
+ ),
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Destination created.',
+ content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 409, description: 'A destination with this network already exists.'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ],
+ )]
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -183,6 +274,32 @@ private function isUniqueConstraintViolation(QueryException $exception): bool
|| in_array($driverCode, ['19', '1062', '2067'], true);
}
+ #[OA\Delete(
+ summary: 'Delete destination',
+ description: 'Delete an unused Docker network destination.',
+ path: '/destinations/{uuid}',
+ operationId: 'delete-destination-by-uuid',
+ security: [['bearerAuth' => []]],
+ tags: ['Destinations'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Destination deleted.',
+ content: new OA\JsonContent(
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'Deleted.'),
+ ],
+ type: 'object',
+ ),
+ ),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 409, description: 'Destination has attached resources.'),
+ ],
+ )]
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php
index 8c82fa2ad..b3685daa4 100644
--- a/app/Http/Controllers/Api/SentinelController.php
+++ b/app/Http/Controllers/Api/SentinelController.php
@@ -143,8 +143,9 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
* health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
- * The force window still refreshes full state periodically. Sorted by name
- * so container ordering from Sentinel does not affect the hash.
+ * The snapshot completeness flag is included so a complete snapshot always
+ * dispatches after a partial snapshot. Sorted by name so container ordering
+ * from Sentinel does not affect the hash.
*/
private function containerStateHash(array $data): string
{
@@ -157,6 +158,14 @@ private function containerStateHash(array $data): string
->values()
->all();
- return hash('xxh128', json_encode($containers));
+ return hash('xxh128', json_encode([
+ 'snapshot_complete' => $this->isCompleteSnapshot($data),
+ 'containers' => $containers,
+ ]));
+ }
+
+ private function isCompleteSnapshot(array $data): bool
+ {
+ return data_get($data, 'snapshot.complete', true) !== false;
}
}
diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php
index 62e98934e..fbf5cd154 100644
--- a/app/Jobs/PushServerUpdateJob.php
+++ b/app/Jobs/PushServerUpdateJob.php
@@ -311,6 +311,10 @@ public function handle()
}
}
+ if (! $this->isCompleteSnapshot()) {
+ return;
+ }
+
$this->updateProxyStatus();
$this->updateNotFoundApplicationStatus();
@@ -329,6 +333,11 @@ public function handle()
$this->checkLogDrainContainer();
}
+ private function isCompleteSnapshot(): bool
+ {
+ return data_get($this->data, 'snapshot.complete', true) !== false;
+ }
+
private function loadApplications(): Collection
{
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
@@ -700,6 +709,9 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
$database->status = $containerStatus;
$database->save();
}
+ if (! $this->isCompleteSnapshot()) {
+ return;
+ }
if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';
diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php
index 7b0151851..a59f23efb 100644
--- a/app/Livewire/Server/New/ByDigitalOcean.php
+++ b/app/Livewire/Server/New/ByDigitalOcean.php
@@ -402,11 +402,11 @@ public function clearCloudInitScript(): void
}
/**
- * @return array{droplet: array, ip: string|null}
+ * Create the droplet on DigitalOcean and return the raw droplet payload.
+ * The public IP may not be assigned yet at this point.
*/
- private function createDigitalOceanDroplet(string $token): array
+ private function createDigitalOceanDroplet(DigitalOceanService $digitalOceanService): array
{
- $digitalOceanService = new DigitalOceanService($token);
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
@@ -442,14 +442,7 @@ private function createDigitalOceanDroplet(string $token): array
$params['user_data'] = $this->cloud_init_script;
}
- $droplet = $digitalOceanService->createDroplet($params);
- $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
- $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
-
- return [
- 'droplet' => $droplet,
- 'ip' => $ipAddress,
- ];
+ return $digitalOceanService->createDroplet($params);
}
public function submit()
@@ -473,17 +466,14 @@ public function submit()
]);
}
- $result = $this->createDigitalOceanDroplet($this->getDigitalOceanToken());
- $droplet = $result['droplet'];
- $ipAddress = $result['ip'];
-
- if (! $ipAddress) {
- throw new \Exception('No public IP address available for the new droplet.');
- }
+ $digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken());
+ $droplet = $this->createDigitalOceanDroplet($digitalOceanService);
+ // Persist the server immediately so the droplet is always tracked
+ // in Coolify, even if waiting for the public IP fails below.
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
- 'ip' => $ipAddress,
+ 'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
@@ -497,6 +487,20 @@ public function submit()
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
+ try {
+ $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
+ $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
+ if ($ipAddress) {
+ $server->update([
+ 'ip' => $ipAddress,
+ 'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
+ ]);
+ }
+ } catch (\Throwable $e) {
+ // Non-fatal: the server page polling backfills the IP later.
+ report($e);
+ }
+
if ($this->from_onboarding) {
currentTeam()->update([
'show_boarding' => false,
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 5ef88acee..1059c6713 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -717,20 +717,19 @@ public function submit()
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
}
- if (! $ipAddress) {
- throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
- }
-
- // Create server in Coolify database
+ // Create server in Coolify database immediately so the Hetzner
+ // server is always tracked, even when no IP is assigned yet —
+ // the server page polling backfills the placeholder IP later.
$server = Server::create([
'name' => $this->server_name,
- 'ip' => $ipAddress,
+ 'ip' => $ipAddress ?? Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'hetzner_server_id' => $hetznerServer['id'],
+ 'hetzner_server_status' => $hetznerServer['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php
index 9e6bc3cf2..fd0aa4c34 100644
--- a/app/Livewire/Server/New/ByVultr.php
+++ b/app/Livewire/Server/New/ByVultr.php
@@ -438,7 +438,7 @@ public function submit(): mixed
$vultrService = new VultrService($this->getVultrToken());
$vultrInstance = $this->createVultrServer($this->getVultrToken());
- $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? '0.0.0.0';
+ $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP;
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
@@ -452,13 +452,18 @@ public function submit(): mixed
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
- $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
- $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
- if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
- $server->update([
- 'ip' => $assignedIpAddress,
- 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
- ]);
+ try {
+ $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
+ $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
+ if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
+ $server->update([
+ 'ip' => $assignedIpAddress,
+ 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
+ ]);
+ }
+ } catch (\Throwable $e) {
+ // Non-fatal: the server page polling backfills the IP later.
+ report($e);
}
$server->proxy->set('status', 'exited');
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 15af859c9..83f4f83d9 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -488,6 +488,11 @@ public function checkHetznerServerStatus(bool $manual = false)
$this->server->hetzner_server_status = $this->hetznerServerStatus;
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
}
+
+ $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip');
+ if ($this->server->backfillPlaceholderIp($assignedIp)) {
+ $this->ip = $this->server->ip;
+ }
if ($manual) {
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
}
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 4bf57207f..928f80fed 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -112,6 +112,13 @@ class Server extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
+ /**
+ * Sentinel IP for servers that do not have a real address yet
+ * (cloud provisioning in progress or parked as unreachable).
+ * Scheduled jobs skip these servers via skipServer().
+ */
+ public const PLACEHOLDER_IP = '1.2.3.4';
+
public static $batch_counter = 0;
/**
@@ -307,6 +314,29 @@ public function type()
return 'server';
}
+ public function hasPlaceholderIp(): bool
+ {
+ // Cast: the saving hook stores the ip as a Stringable in memory.
+ $ip = (string) $this->ip;
+
+ return blank($ip) || in_array($ip, [self::PLACEHOLDER_IP, '0.0.0.0', '::'], true);
+ }
+
+ /**
+ * Replace a placeholder IP with the real address once the cloud
+ * provider reports one. Returns true when the IP was updated.
+ */
+ public function backfillPlaceholderIp(?string $ip): bool
+ {
+ if ($ip && $this->hasPlaceholderIp()) {
+ $this->update(['ip' => $ip]);
+
+ return true;
+ }
+
+ return false;
+ }
+
public function refreshVultrState(): ?string
{
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
@@ -339,8 +369,7 @@ public function refreshVultrState(): ?string
$updates['vultr_instance_status'] = $status;
}
- $hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true);
- if ($hasPlaceholderIp && $publicIp) {
+ if ($this->hasPlaceholderIp() && $publicIp) {
$updates['ip'] = $publicIp;
}
@@ -388,7 +417,7 @@ public function refreshDigitalOceanState(): ?string
$ip = $digitalOceanService->getPublicIpAddress($droplet);
$updates = ['digitalocean_droplet_status' => $status];
- if ($ip && $ip !== $this->ip) {
+ if ($ip && $this->hasPlaceholderIp()) {
$updates['ip'] = $ip;
}
@@ -1176,7 +1205,7 @@ public function isProxyShouldRun()
public function skipServer()
{
- if ($this->ip === '1.2.3.4') {
+ if ($this->hasPlaceholderIp()) {
return true;
}
if ($this->settings->force_disabled === true) {
@@ -1188,7 +1217,7 @@ public function skipServer()
public function isFunctional()
{
- $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4';
+ $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp();
if ($isFunctional === false) {
Storage::disk('ssh-mux')->delete($this->muxFilename());
diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php
index c1dd4bf67..604a245fc 100644
--- a/app/Models/StandaloneDocker.php
+++ b/app/Models/StandaloneDocker.php
@@ -7,7 +7,22 @@
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use OpenApi\Attributes as OA;
+#[OA\Schema(
+ schema: 'Destination',
+ description: 'A Docker network destination attached to a server.',
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'uuid', type: 'string'),
+ new OA\Property(property: 'name', type: 'string'),
+ new OA\Property(property: 'network', type: 'string'),
+ new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
+ new OA\Property(property: 'server_uuid', type: 'string'),
+ new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
+ new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
+ ],
+)]
class StandaloneDocker extends BaseModel
{
use HasFactory;
@@ -23,6 +38,10 @@ protected static function boot()
{
parent::boot();
static::created(function ($newStandaloneDocker) {
+ if (app()->runningUnitTests()) {
+ return;
+ }
+
$server = $newStandaloneDocker->server;
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
instant_remote_process([
diff --git a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php
index 500fa1fcf..98b0c73c2 100644
--- a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php
+++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php
@@ -15,7 +15,7 @@ public function up(): void
DB::table('cloud_init_scripts')
->whereNull('uuid')
- ->orderBy('id')
+ ->lazyById()
->each(function (object $script): void {
DB::table('cloud_init_scripts')
->where('id', $script->id)
diff --git a/openapi.json b/openapi.json
index 7ffe9ecca..4f34d3f65 100644
--- a/openapi.json
+++ b/openapi.json
@@ -8059,6 +8059,260 @@
]
}
},
+ "\/destinations": {
+ "get": {
+ "tags": [
+ "Destinations"
+ ],
+ "summary": "List destinations",
+ "description": "List all Docker network destinations for the authenticated team.",
+ "operationId": "list-destinations",
+ "responses": {
+ "200": {
+ "description": "Destinations for the authenticated team.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Destination"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/servers\/{server_uuid}\/destinations": {
+ "get": {
+ "tags": [
+ "Destinations"
+ ],
+ "summary": "List destinations by server",
+ "description": "List Docker network destinations attached to a server owned by the authenticated team.",
+ "operationId": "list-server-destinations",
+ "parameters": [
+ {
+ "name": "server_uuid",
+ "in": "path",
+ "description": "Server UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Destinations attached to the server.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Destination"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Destinations"
+ ],
+ "summary": "Create destination",
+ "description": "Create a Docker network destination on a server owned by the authenticated team.",
+ "operationId": "create-server-destination",
+ "parameters": [
+ {
+ "name": "server_uuid",
+ "in": "path",
+ "description": "Server UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "network"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "network": {
+ "type": "string",
+ "maxLength": 255,
+ "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "standalone",
+ "swarm"
+ ]
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Destination created.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/Destination"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "409": {
+ "description": "A destination with this network already exists."
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/destinations\/{uuid}": {
+ "get": {
+ "tags": [
+ "Destinations"
+ ],
+ "summary": "Get destination",
+ "description": "Get a Docker network destination by UUID.",
+ "operationId": "get-destination-by-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Destination UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Destination details.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/Destination"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "delete": {
+ "tags": [
+ "Destinations"
+ ],
+ "summary": "Delete destination",
+ "description": "Delete an unused Docker network destination.",
+ "operationId": "delete-destination-by-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Destination UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Destination deleted.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Deleted."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "409": {
+ "description": "Destination has attached resources."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/digitalocean\/regions": {
"get": {
"tags": [
@@ -15514,6 +15768,39 @@
},
"type": "object"
},
+ "Destination": {
+ "description": "A Docker network destination attached to a server.",
+ "properties": {
+ "uuid": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "network": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "standalone",
+ "swarm"
+ ]
+ },
+ "server_uuid": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "type": "object"
+ },
"Tag": {
"description": "Tag model",
"properties": {
@@ -15754,6 +16041,10 @@
"name": "Deployments",
"description": "Deployments"
},
+ {
+ "name": "Destinations",
+ "description": "Destinations"
+ },
{
"name": "DigitalOcean",
"description": "DigitalOcean"
diff --git a/openapi.yaml b/openapi.yaml
index 3b2f5c4d5..55751abd5 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -5232,6 +5232,170 @@ paths:
security:
-
bearerAuth: []
+ /destinations:
+ get:
+ tags:
+ - Destinations
+ summary: 'List destinations'
+ description: 'List all Docker network destinations for the authenticated team.'
+ operationId: list-destinations
+ responses:
+ '200':
+ description: 'Destinations for the authenticated team.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Destination'
+ '401':
+ $ref: '#/components/responses/401'
+ security:
+ -
+ bearerAuth: []
+ '/servers/{server_uuid}/destinations':
+ get:
+ tags:
+ - Destinations
+ summary: 'List destinations by server'
+ description: 'List Docker network destinations attached to a server owned by the authenticated team.'
+ operationId: list-server-destinations
+ parameters:
+ -
+ name: server_uuid
+ in: path
+ description: 'Server UUID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Destinations attached to the server.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Destination'
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - Destinations
+ summary: 'Create destination'
+ description: 'Create a Docker network destination on a server owned by the authenticated team.'
+ operationId: create-server-destination
+ parameters:
+ -
+ name: server_uuid
+ in: path
+ description: 'Server UUID'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - network
+ properties:
+ name:
+ type: string
+ maxLength: 255
+ network:
+ type: string
+ maxLength: 255
+ pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'
+ type:
+ type: string
+ enum: [standalone, swarm]
+ type: object
+ responses:
+ '201':
+ description: 'Destination created.'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Destination'
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ '409':
+ description: 'A destination with this network already exists.'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/destinations/{uuid}':
+ get:
+ tags:
+ - Destinations
+ summary: 'Get destination'
+ description: 'Get a Docker network destination by UUID.'
+ operationId: get-destination-by-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Destination UUID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Destination details.'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Destination'
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ delete:
+ tags:
+ - Destinations
+ summary: 'Delete destination'
+ description: 'Delete an unused Docker network destination.'
+ operationId: delete-destination-by-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Destination UUID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Destination deleted.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: Deleted. }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ '409':
+ description: 'Destination has attached resources.'
+ security:
+ -
+ bearerAuth: []
/digitalocean/regions:
get:
tags:
@@ -9958,6 +10122,29 @@ components:
type: string
description: 'The date and time when the service was deleted.'
type: object
+ Destination:
+ description: 'A Docker network destination attached to a server.'
+ properties:
+ uuid:
+ type: string
+ name:
+ type: string
+ network:
+ type: string
+ type:
+ type: string
+ enum:
+ - standalone
+ - swarm
+ server_uuid:
+ type: string
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+ type: object
Tag:
description: 'Tag model'
properties:
@@ -10117,6 +10304,9 @@ tags:
-
name: Deployments
description: Deployments
+ -
+ name: Destinations
+ description: Destinations
-
name: DigitalOcean
description: DigitalOcean
diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php
index 2d08f9c3e..eab84a611 100644
--- a/resources/views/livewire/server/navbar.blade.php
+++ b/resources/views/livewire/server/navbar.blade.php
@@ -15,7 +15,7 @@