From f9f53f2fea1a775c51aae101b02cc3b512c134ff Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Thu, 27 Aug 2026 04:36:12 +0000 Subject: [PATCH 01/48] fix(notifications): implement toWebhook() for always-send notifications WebhookChannel::send() calls toWebhook() unconditionally, but the five notifications reachable through alwaysSendEvents did not implement it, so enabling the webhook channel turned those events into fatal queued jobs. --- .../ApiTokenExpiringNotification.php | 12 ++ .../Internal/GeneralNotification.php | 10 ++ app/Notifications/Server/ForceDisabled.php | 12 ++ app/Notifications/Server/ForceEnabled.php | 12 ++ .../SslExpirationNotification.php | 14 +++ .../Channels/WebhookChannelTest.php | 113 ++++++++++++++++++ 6 files changed, 173 insertions(+) create mode 100644 tests/Unit/Notifications/Channels/WebhookChannelTest.php diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 451dd312a1..c5567a3a64 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -100,4 +100,16 @@ class ApiTokenExpiringNotification extends CustomEmailNotification color: SlackMessage::warningColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "API token '{$this->tokenName}' expires on {$this->expiresAt}. Rotate this token before it expires to avoid API outages.", + 'event' => 'api_token_expiring', + 'token_name' => $this->tokenName, + 'expires_at' => $this->expiresAt, + 'url' => $this->manageUrl, + ]; + } } diff --git a/app/Notifications/Internal/GeneralNotification.php b/app/Notifications/Internal/GeneralNotification.php index 1d23672100..52e986ed6e 100644 --- a/app/Notifications/Internal/GeneralNotification.php +++ b/app/Notifications/Internal/GeneralNotification.php @@ -58,4 +58,14 @@ class GeneralNotification extends Notification implements ShouldQueue color: SlackMessage::infoColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => $this->message, + 'event' => 'general', + 'url' => base_url(), + ]; + } } diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 4b56f5860b..2d2ebabaf0 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -74,4 +74,16 @@ class ForceDisabled extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "Server ({$this->server->name}) disabled because it is not paid! All automations and integrations are stopped.", + 'event' => 'server_force_disabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 36dad3c60f..61022d36b5 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -65,4 +65,16 @@ class ForceEnabled extends CustomEmailNotification color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => "Server ({$this->server->name}) enabled again!", + 'event' => 'server_force_enabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 78e1e8be9c..73c7a665d3 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -148,4 +148,18 @@ class SslExpirationNotification extends CustomEmailNotification color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $resourceNames = $this->resources->pluck('name'); + + return [ + 'success' => false, + 'message' => "SSL certificates have been renewed for: {$resourceNames->join(', ')}. These resources need to be redeployed manually for the new SSL certificates to take effect.", + 'event' => 'ssl_certificate_renewal', + 'resources' => $resourceNames->values()->all(), + 'urls' => $this->urls, + 'url' => base_url(), + ]; + } } diff --git a/tests/Unit/Notifications/Channels/WebhookChannelTest.php b/tests/Unit/Notifications/Channels/WebhookChannelTest.php new file mode 100644 index 0000000000..783159e69e --- /dev/null +++ b/tests/Unit/Notifications/Channels/WebhookChannelTest.php @@ -0,0 +1,113 @@ + InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'fqdn' => 'https://coolify.example.com', + ])); + Queue::fake(); + + $this->team = Team::create([ + 'name' => 'Webhook Channel Team', + 'personal_team' => false, + 'show_boarding' => false, + ]); + // Assign through the model so the `encrypted` cast on webhook_url is applied. + $settings = $this->team->webhookNotificationSettings; + $settings->webhook_enabled = true; + $settings->webhook_url = 'https://webhook.example.com/coolify'; + $settings->save(); + $this->team->refresh(); +}); + +/** + * Send a notification through the webhook channel and return the dispatched payload. + * + * @return array + */ +function deliverOverWebhook(Team $team, Notification $notification): array +{ + expect($notification->via($team))->toContain(WebhookChannel::class); + + (new WebhookChannel)->send($team, $notification); + + $payload = null; + Queue::assertPushed(SendWebhookJob::class, function (SendWebhookJob $job) use (&$payload) { + $payload = $job->payload; + + return true; + }); + + expect($payload)->toBeArray() + ->and($payload['success'])->toBeBool() + ->and($payload['message'])->toBeString()->not->toBeEmpty(); + + return $payload; +} + +it('delivers ssl certificate renewal notifications over the webhook channel', function () { + $payload = deliverOverWebhook( + $this->team, + new SslExpirationNotification([(object) ['name' => 'my-application']]) + ); + + expect($payload['event'])->toBe('ssl_certificate_renewal') + ->and($payload['resources'])->toBe(['my-application']); +}); + +it('delivers api token expiring notifications over the webhook channel', function () { + $token = new PersonalAccessToken([ + 'name' => 'ci-token', + 'expires_at' => now()->addDay(), + ]); + + $payload = deliverOverWebhook($this->team, new ApiTokenExpiringNotification($token)); + + expect($payload['event'])->toBe('api_token_expiring') + ->and($payload['token_name'])->toBe('ci-token'); +}); + +it('delivers server force enabled notifications over the webhook channel', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $payload = deliverOverWebhook($this->team, new ForceEnabled($server)); + + expect($payload['event'])->toBe('server_force_enabled') + ->and($payload['success'])->toBeTrue() + ->and($payload['server_uuid'])->toBe($server->uuid); +}); + +it('delivers server force disabled notifications over the webhook channel', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + $payload = deliverOverWebhook($this->team, new ForceDisabled($server)); + + expect($payload['event'])->toBe('server_force_disabled') + ->and($payload['success'])->toBeFalse() + ->and($payload['server_uuid'])->toBe($server->uuid); +}); + +it('delivers general notifications over the webhook channel', function () { + $payload = deliverOverWebhook($this->team, new GeneralNotification('Something happened')); + + expect($payload['event'])->toBe('general') + ->and($payload['message'])->toBe('Something happened'); +}); From b7f00e72676558bc1e75918e3eabaeb9232a75b1 Mon Sep 17 00:00:00 2001 From: kashik0i Date: Fri, 28 Aug 2026 02:45:17 +0300 Subject: [PATCH 02/48] fix(docker): detect unqualified helper images --- app/Jobs/CleanupHelperContainersJob.php | 7 +- tests/Unit/CleanupHelperContainersJobTest.php | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/CleanupHelperContainersJobTest.php diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index f1635d6d4d..425dd2591c 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -19,6 +19,11 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S public function __construct(public Server $server) {} + private static function helperContainersCommand(): string + { + return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("^([^/]+/)?coollabsio/coolify-helper(:|@)")))\''; + } + public function handle(): void { try { @@ -36,7 +41,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S 'active_deployment_uuids' => $activeDeployments, ]); - $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false); + $containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false); $helperContainers = collect(json_decode($containers)); if ($helperContainers->count() > 0) { diff --git a/tests/Unit/CleanupHelperContainersJobTest.php b/tests/Unit/CleanupHelperContainersJobTest.php new file mode 100644 index 0000000000..a649985d28 --- /dev/null +++ b/tests/Unit/CleanupHelperContainersJobTest.php @@ -0,0 +1,69 @@ +invoke(null); + $directory = sys_get_temp_dir().'/coolify-helper-filter-'.bin2hex(random_bytes(4)); + $docker = $directory.'/docker'; + $images = [ + 'coollabsio/coolify-helper:1.0.15', + 'docker.io/coollabsio/coolify-helper:1.0.16', + 'ghcr.io/coollabsio/coolify-helper@sha256:abc', + 'registry.example/team/coollabsio/coolify-helper:latest', + 'evil/coollabsio/coolify-helper-copy:latest', + 'coollabsio/not-coolify-helper:latest', + ]; + + mkdir($directory); + file_put_contents($docker, "#!/bin/sh\n".implode("\n", array_map( + fn (string $image): string => 'echo '.escapeshellarg(json_encode(['Image' => $image], JSON_THROW_ON_ERROR)), + $images + ))."\n"); + chmod($docker, 0755); + + try { + $process = new Process(['/bin/sh', '-c', $command], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + ]); + $process->mustRun(); + + expect(array_column(json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), 'Image')) + ->toBe(array_slice($images, 0, 3)); + } finally { + unlink($docker); + rmdir($directory); + } +}); + +it('preserves the helper image filter for non-root servers', function () { + $command = (new ReflectionMethod(CleanupHelperContainersJob::class, 'helperContainersCommand'))->invoke(null); + $server = Mockery::mock(Server::class)->makePartial(); + $server->user = 'ubuntu'; + $command = parseCommandsByLineForSudo(collect([$command]), $server)[0]; + $directory = sys_get_temp_dir().'/coolify-helper-sudo-filter-'.bin2hex(random_bytes(4)); + $docker = $directory.'/docker'; + $sudo = $directory.'/sudo'; + + mkdir($directory); + file_put_contents($docker, "#!/bin/sh\necho '{\"Image\":\"coollabsio/coolify-helper:1.0.15\"}'\n"); + file_put_contents($sudo, "#!/bin/sh\nexec \"\$@\"\n"); + chmod($docker, 0755); + chmod($sudo, 0755); + + try { + $process = new Process(['/bin/sh', '-c', $command], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + ]); + $process->mustRun(); + + expect(array_column(json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), 'Image')) + ->toBe(['coollabsio/coolify-helper:1.0.15']); + } finally { + unlink($docker); + unlink($sudo); + rmdir($directory); + } +}); From 76414296a6eb2bff60e5969e4ef02e35b11d1922 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:12:06 +0200 Subject: [PATCH 03/48] fix(ui): typo on storage delete modal (#11541) --- app/Livewire/Project/Service/FileStorage.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index d6ab2ac151..cd209f6ae9 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -308,10 +308,10 @@ class FileStorage extends Component { return view('livewire.project.service.file-storage', [ 'directoryDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'], + ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'], ], 'fileDeletionCheckboxes' => [ - ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], + ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'], ], 'hostFileDeletionCheckboxes' => [ ['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'], From 45466b50695f231851c83fcd57c5dd0a9606e547 Mon Sep 17 00:00:00 2001 From: Devin Dissanayaka Date: Thu, 3 Sep 2026 22:05:07 +0530 Subject: [PATCH 04/48] fix(notifications): send traefik outdated alerts to the correct topic id (#11526) --- .../Channels/TelegramChannel.php | 3 +- .../TelegramNotificationThreadRoutingTest.php | 89 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/TelegramNotificationThreadRoutingTest.php diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php index 4f311bf681..a52feda087 100644 --- a/app/Notifications/Channels/TelegramChannel.php +++ b/app/Notifications/Channels/TelegramChannel.php @@ -17,6 +17,7 @@ use App\Notifications\Server\DockerCleanupSuccess; use App\Notifications\Server\HighDiskUsage; use App\Notifications\Server\Reachable; use App\Notifications\Server\ServerPatchCheck; +use App\Notifications\Server\TraefikVersionOutdated; use App\Notifications\Server\Unreachable; class TelegramChannel @@ -50,7 +51,7 @@ class TelegramChannel Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, - + TraefikVersionOutdated::class => $settings->telegram_notifications_traefik_outdated_thread_id, default => null, }; diff --git a/tests/Feature/TelegramNotificationThreadRoutingTest.php b/tests/Feature/TelegramNotificationThreadRoutingTest.php new file mode 100644 index 0000000000..474d2b157d --- /dev/null +++ b/tests/Feature/TelegramNotificationThreadRoutingTest.php @@ -0,0 +1,89 @@ + InstanceSettings::query()->firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->team->telegramNotificationSettings->update([ + 'telegram_enabled' => true, + 'telegram_token' => 'test-token', + 'telegram_chat_id' => '-1001234567890', + ]); + + Queue::fake(); +}); + +function telegramTestServer(Team $team): Server +{ + return Server::factory()->make([ + 'name' => 'Test Server', + 'uuid' => 'test-uuid', + 'team_id' => $team->id, + ]); +} + +function outdatedTraefikServer(Team $team): Server +{ + $server = telegramTestServer($team); + + $server->outdatedInfo = [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]; + + return $server; +} + +it('sends the traefik outdated notification to its configured topic', function () { + $this->team->telegramNotificationSettings->update([ + 'telegram_notifications_traefik_outdated_thread_id' => '42', + ]); + + $notification = new TraefikVersionOutdated(collect([outdatedTraefikServer($this->team)])); + + (new TelegramChannel)->send($this->team->fresh(), $notification); + + Queue::assertPushed( + SendMessageToTelegramJob::class, + fn (SendMessageToTelegramJob $job) => $job->threadId === '42' + ); +}); + +it('sends the server patch notification to its configured topic', function () { + $this->team->telegramNotificationSettings->update([ + 'telegram_notifications_server_patch_thread_id' => '7', + ]); + + $notification = new ServerPatchCheck(telegramTestServer($this->team), ['total_updates' => 3]); + + (new TelegramChannel)->send($this->team->fresh(), $notification); + + Queue::assertPushed( + SendMessageToTelegramJob::class, + fn (SendMessageToTelegramJob $job) => $job->threadId === '7' + ); +}); + +it('falls back to the main chat when no topic is configured', function () { + $notification = new TraefikVersionOutdated(collect([outdatedTraefikServer($this->team)])); + + (new TelegramChannel)->send($this->team->fresh(), $notification); + + Queue::assertPushed( + SendMessageToTelegramJob::class, + fn (SendMessageToTelegramJob $job) => $job->threadId === null + ); +}); From 1d0d6b4a79f3280584ffb57409bdc4e309cb7dd0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:23:08 +0200 Subject: [PATCH 05/48] fix(scheduler): run stuck resource cleanup in background at 03:17 Bump the Coolify release version to 4.3.16 and update schedule coverage. --- app/Console/Kernel.php | 5 +++-- config/constants.php | 2 +- other/nightly/versions.json | 2 +- tests/Feature/ScheduleOnOneServerTest.php | 8 +++++--- tests/Unit/ProductionImageWorkflowTest.php | 4 ++-- versions.json | 2 +- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8d4d017c81..8eb010b8ac 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -47,9 +47,10 @@ class Kernel extends ConsoleKernel ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $this->scheduleInstance->command('cleanup:stucked-resources') - ->daily() + ->dailyAt('03:17') ->onOneServer() - ->withoutOverlapping(60); + ->withoutOverlapping(60) + ->runInBackground(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); diff --git a/config/constants.php b/config/constants.php index 457a8a1ff8..d2e2a9f33d 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.15', + 'version' => env('COOLIFY_VERSION') ?: '4.3.16', 'helper_version' => '1.0.16', 'realtime_version' => '1.0.18', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 455918fdc0..a3a660f9cb 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.15" + "version": "4.3.16" }, "nightly": { "version": "4.4-rc.1" diff --git a/tests/Feature/ScheduleOnOneServerTest.php b/tests/Feature/ScheduleOnOneServerTest.php index ef2d80e648..3ad3808631 100644 --- a/tests/Feature/ScheduleOnOneServerTest.php +++ b/tests/Feature/ScheduleOnOneServerTest.php @@ -59,7 +59,7 @@ it('does not schedule Stripe subscription reconciliation automatically', functio expect($event)->toBeNull(); }); -it('schedules stuck resource cleanup once per day on one server', function () { +it('schedules stuck resource cleanup in the background once per day', function () { $schedule = app(Schedule::class); $event = collect($schedule->events())->first( @@ -67,6 +67,8 @@ it('schedules stuck resource cleanup once per day on one server', function () { ); expect($event)->not->toBeNull() - ->and($event->expression)->toBe('0 0 * * *') - ->and($event->onOneServer)->toBeTrue(); + ->and($event->expression)->toBe('17 3 * * *') + ->and($event->onOneServer)->toBeTrue() + ->and($event->withoutOverlapping)->toBeTrue() + ->and($event->runInBackground)->toBeTrue(); }); diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 293c9422e9..4e6229d073 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.15'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.15') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.16'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.16') ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); diff --git a/versions.json b/versions.json index 455918fdc0..a3a660f9cb 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.15" + "version": "4.3.16" }, "nightly": { "version": "4.4-rc.1" From 986ece457d4e13304c30a6f81c27717ac800280b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:38:19 +0200 Subject: [PATCH 06/48] fix(domains): use Compose service ports for internal routing Detect Docker Compose service ports for domain internal ports and proxy labels, with application ports as a fallback. --- app/Livewire/Project/Application/Domains.php | 12 +++++- .../Project/Application/PreviewDomains.php | 12 +++++- bootstrap/helpers/docker.php | 33 +++++++++++++++ bootstrap/helpers/parsers.php | 2 +- bootstrap/helpers/shared.php | 2 +- tests/Feature/ApplicationDomainsTest.php | 42 +++++++++++++++++++ ...licationParserDockerComposeDomainsTest.php | 32 ++++++++++++++ 7 files changed, 129 insertions(+), 6 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 9f1e7fc176..d7797935f5 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -500,7 +500,7 @@ class Domains extends Component { $key = $this->domainDnsStatusKey($url, $service); $entry = $stored[$key] ?? null; - $port = $this->effectiveDomainInternalPort($url); + $port = $this->effectiveDomainInternalPort($url, $service); $row = [ 'url' => $url, @@ -532,7 +532,7 @@ class Domains extends Component /** * @return array{internal_port: ?int, has_port_override: bool} */ - protected function effectiveDomainInternalPort(string $url): array + protected function effectiveDomainInternalPort(string $url, ?string $service = null): array { $canonical = DomainPortOverrides::withoutPort($url); $overrides = $this->application->domain_port_overrides ?? []; @@ -561,6 +561,14 @@ class Domains extends Component ]; } + $composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service); + if ($composePort !== null) { + return [ + 'internal_port' => $composePort, + 'has_port_override' => false, + ]; + } + $exposed = $this->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 21296978f7..e6e9f5d60f 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -439,7 +439,7 @@ class PreviewDomains extends Component private function makeRow(string $url, ?string $service, array $statuses = []): array { $status = $statuses[$this->statusKey($url, $service)] ?? []; - $port = $this->effectiveDomainInternalPort($url); + $port = $this->effectiveDomainInternalPort($url, $service); return [ 'url' => $url, @@ -500,7 +500,7 @@ class PreviewDomains extends Component /** * @return array{internal_port: ?int, has_port_override: bool} */ - private function effectiveDomainInternalPort(string $url): array + private function effectiveDomainInternalPort(string $url, ?string $service = null): array { $canonical = DomainPortOverrides::withoutPort($url); $overrides = $this->preview->domain_port_overrides ?? []; @@ -529,6 +529,14 @@ class PreviewDomains extends Component ]; } + $composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service); + if ($composePort !== null) { + return [ + 'internal_port' => $composePort, + 'has_port_override' => false, + ]; + } + $exposed = $this->preview->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f80fccafb2..84232a8d72 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -601,6 +601,39 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, return $labels->sort(); } +function firstDockerComposeServicePort(mixed $service): ?int +{ + $portDefinitions = collect(data_get($service, 'expose', [])) + ->merge(data_get($service, 'ports', [])); + + foreach ($portDefinitions as $definition) { + $port = is_array($definition) + ? data_get($definition, 'target') + : str((string) $definition)->before('/')->afterLast(':')->value(); + + if (is_numeric($port) && (int) $port >= 1 && (int) $port <= 65535) { + return (int) $port; + } + } + + return null; +} + +function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int +{ + if (blank($compose) || blank($serviceName)) { + return null; + } + + try { + $services = data_get(Yaml::parse($compose), 'services', []); + } catch (Throwable) { + return null; + } + + return firstDockerComposeServicePort(is_array($services) ? ($services[$serviceName] ?? null) : null); +} + function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) { $labels = collect([]); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index f65b626984..590b68d162 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1358,7 +1358,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ? ($previewForPorts?->domain_port_overrides ?? []) : ($originalResource->domain_port_overrides ?? []); $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; - $onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null; + $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 9f18c466da..f9bf44dffa 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3916,7 +3916,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal ? ($resource->domain_port_overrides ?? []) : ($preview?->domain_port_overrides ?? []); $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; - $onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null; + $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 9d0739d1ce..19dca79661 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2311,6 +2311,48 @@ it('distinguishes an inherited internal port from a domain port override', funct ->assertDontSee('Custom internal port for this domain', false); }); +it('shows the detected compose service port as the inherited internal port', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose:\n - '8069'\n", + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://example.com'], + ]), + 'fqdn' => null, + 'domain_port_overrides' => null, + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSet('domainRows.0.internal_port', 8069) + ->assertSet('domainRows.0.has_port_override', false) + ->assertSee('Internal port 8069') + ->assertDontSee('Internal port 3000'); +}); + +it('shows the detected compose service port for preview domains', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n ports:\n - '18069:8069'\n", + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 8069, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/8069', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://preview.example.com'], + ]), + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertSet('domainRows.0.internal_port', 8069) + ->assertSet('domainRows.0.has_port_override', false) + ->assertSee('Internal port 8069') + ->assertDontSee('Internal port 3000'); +}); + it('keeps a legacy port-bearing url port in the edit field as an internal port override', function () { $this->application->update([ 'ports_exposes' => '3000,8080', diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 0c40d4a86c..30305ef596 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -599,3 +599,35 @@ YAML, ->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`frontend.example.com`)'))) ->toBeTrue(); }); + +test('applicationParser compose labels prefer the service exposed port over application ports_exposes', function (string $portConfiguration) { + $application = disableExactProxyLabels(Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => << null, + 'domain_port_overrides' => null, + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://frontend.example.com'], + ]), + ])); + + $labels = collect(data_get(applicationParser($application->fresh()), 'services.frontend.labels')); + + expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=8069'))) + ->toBeTrue() + ->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 8069}}'))) + ->toBeTrue(); +})->with([ + 'expose' => " expose:\n - '8069'", + 'short port syntax' => " ports:\n - '18069:8069'", + 'long port syntax' => " ports:\n - target: 8069\n published: 18069", +]); From d0b77b26375a94e0e5e530d8312d6d23b63c453b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:51:53 +0200 Subject: [PATCH 07/48] chore(release): bump Coolify version to 4.3.17 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- tests/Unit/ProductionImageWorkflowTest.php | 4 ++-- versions.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/constants.php b/config/constants.php index d2e2a9f33d..7ad0c4594f 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.16', + 'version' => env('COOLIFY_VERSION') ?: '4.3.17', 'helper_version' => '1.0.16', 'realtime_version' => '1.0.18', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index a3a660f9cb..0e83c0cf74 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.16" + "version": "4.3.17" }, "nightly": { "version": "4.4-rc.1" diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 4e6229d073..140a6276f2 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.16'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.16') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.17'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.17') ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); diff --git a/versions.json b/versions.json index a3a660f9cb..0e83c0cf74 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.16" + "version": "4.3.17" }, "nightly": { "version": "4.4-rc.1" From 89a66dd255262f9a105dc9d4007c8aefe9f8456a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:30:35 +0200 Subject: [PATCH 08/48] fix(service): show disabled deploy action when variables are missing --- .../views/livewire/project/service/heading.blade.php | 6 ++++++ tests/Feature/ResourceHeadingUnifiedNavbarTest.php | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/project/service/heading.blade.php b/resources/views/livewire/project/service/heading.blade.php index e7da50c809..79899e8795 100644 --- a/resources/views/livewire/project/service/heading.blade.php +++ b/resources/views/livewire/project/service/heading.blade.php @@ -188,6 +188,12 @@ @endcan @else + @can('deploy', $service) + + @endcan diff --git a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php index 330afa7edf..ce69f26ef7 100644 --- a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php +++ b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php @@ -84,11 +84,19 @@ it('docks desktop resource actions in the top bar instead of floating over conte it('links the service header missing variables warning to environment variables', function () { $heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); + $mobileActions = str($heading) + ->after('
') + ->before("@teleport('#resource-action-hud-slot')") + ->toString(); expect($heading) ->toContain("route('project.service.environment-variables'") ->toContain('Required variables missing') - ->toContain('href="{{ $environmentVariablesUrl }}"'); + ->toContain('href="{{ $environmentVariablesUrl }}"') + ->and($mobileActions) + ->toContain('Fill required variables first') + ->toContain('disabled') + ->toContain('Deploy'); }); it('places the account menu beside the desktop sidebar toggle while retaining it on mobile', function () { From 6abbf84520bdfc245c3346661fd6b7fd1b50e113 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:43:07 +0200 Subject: [PATCH 09/48] fix(notifications): build api token expiry notification link from the instance url --- app/Notifications/ApiTokenExpiringNotification.php | 2 +- tests/Feature/ApiTokenExpirationWarningTest.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 451dd312a1..40bd3ead0d 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -21,7 +21,7 @@ class ApiTokenExpiringNotification extends CustomEmailNotification $this->onQueue('high'); $this->tokenName = $token->name; $this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? ''; - $this->manageUrl = route('security.api-tokens'); + $this->manageUrl = base_url().'/security/api-tokens'; } public function via(object $notifiable): array diff --git a/tests/Feature/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php index 92c2076077..a6bbffbbc3 100644 --- a/tests/Feature/ApiTokenExpirationWarningTest.php +++ b/tests/Feature/ApiTokenExpirationWarningTest.php @@ -1,6 +1,7 @@ 0]); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -137,4 +139,15 @@ describe('ApiTokenExpirationWarningJob', function () { Notification::assertNothingSent(); expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull(); }); + + test('manage url uses the instance fqdn when configured', function () { + InstanceSettings::query()->update(['fqdn' => 'https://coolify.example.com']); + $token = createTokenExpiring($this->user, $this->team, Carbon::now()->addHours(12)); + + $notification = new ApiTokenExpiringNotification($token); + + expect($notification->toSlack()->description) + ->toContain('https://coolify.example.com/security/api-tokens') + ->not->toContain('localhost'); + }); }); From 306d4833a99f58cb2931b2f35628e9fe9989a0ca Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:45:40 +0200 Subject: [PATCH 10/48] fix(notifications): build restart limit links from the instance url --- .../Application/RestartLimitReached.php | 22 ++++++--- ...pplicationStoppedAfterRestartLimitTest.php | 49 +++++++++++++++---- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 687fd30867..de9de1f981 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -2,8 +2,11 @@ namespace App\Notifications\Application; +use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\BaseModel; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; @@ -49,14 +52,19 @@ class RestartLimitReached extends CustomEmailNotification if (str($this->fqdn)->explode(',')->count() > 1) { $this->fqdn = str($this->fqdn)->explode(',')->first(); } - $service = data_get($resource, 'service'); - $this->resource_url = match (true) { - method_exists($this->resource, 'link') => $this->resource->link(), - $resource instanceof ApplicationPreview => $resource->application->link(), - is_object($service) && method_exists($service, 'link') => $service->link(), - default => null, + $this->resource_url = $this->resolveResourceUrl($resource); + } + + private function resolveResourceUrl(BaseModel $resource): string + { + [$type, $uuid] = match (true) { + $resource instanceof Application => ['application', $resource->uuid], + $resource instanceof ApplicationPreview => ['application', $resource->application->uuid], + $resource instanceof ServiceApplication, $resource instanceof ServiceDatabase => ['service', $resource->service->uuid], + default => ['database', $resource->uuid], }; - $this->resource_url ??= base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}"; + + return base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/{$type}/{$uuid}"; } public function via(object $notifiable): array diff --git a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php index 3e6e04629d..58ebb8c703 100644 --- a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php +++ b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php @@ -6,10 +6,21 @@ use App\Jobs\ApplicationDeploymentJob; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\BaseModel; +use App\Models\InstanceSettings; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\StandalonePostgresql; use App\Notifications\Application\RestartLimitReached; +use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery\MockInterface; +uses(RefreshDatabase::class); + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0, 'fqdn' => 'https://coolify.test']); +}); + function applicationWithRestartState(array $attributes = []): Application { $application = new Application; @@ -161,14 +172,8 @@ it('preserves restart-limit applications only while their exited container exist ->and($sentinelJob)->toContain('if ($application->stoppedAfterRestartLimit() && $containerStatuses->every('); }); -it('uses the application link for restart limit notifications', function () { - $application = new class extends Application - { - public function link() - { - return 'https://coolify.test/project/link-from-model'; - } - }; +it('builds restart limit notification urls from the instance base url', function () { + $application = new Application; $application->forceFill([ 'name' => 'crashy-app', 'uuid' => 'application-uuid', @@ -183,7 +188,33 @@ it('uses the application link for restart limit notifications', function () { $notification = new RestartLimitReached($application); - expect($notification->resource_url)->toBe('https://coolify.test/project/link-from-model'); + expect($notification->resource_url)->toBe('https://coolify.test/project/project-uuid/environment/environment-uuid/application/application-uuid'); +}); + +it('links preview, service resource and database restart limit notifications to their pages', function () { + $environment = (object) ['uuid' => 'environment-uuid', 'name' => 'production', 'project' => (object) ['uuid' => 'project-uuid']]; + + $application = new Application; + $application->forceFill(['name' => 'app', 'uuid' => 'application-uuid']); + $application->setRelation('environment', $environment); + $preview = new ApplicationPreview; + $preview->forceFill(['uuid' => 'preview-uuid', 'pull_request_id' => 42, 'restart_count' => 2, 'max_restart_count' => 2]); + $preview->setRelation('application', $application); + + $service = new Service; + $service->forceFill(['uuid' => 'service-uuid']); + $service->setRelation('environment', $environment); + $serviceApplication = new ServiceApplication; + $serviceApplication->forceFill(['name' => 'database', 'uuid' => 'service-application-uuid', 'restart_count' => 2, 'max_restart_count' => 2]); + $serviceApplication->setRelation('service', $service); + + $database = new StandalonePostgresql; + $database->forceFill(['name' => 'postgres', 'uuid' => 'database-uuid', 'restart_count' => 2, 'max_restart_count' => 2]); + $database->setRelation('environment', $environment); + + expect((new RestartLimitReached($preview))->resource_url)->toBe('https://coolify.test/project/project-uuid/environment/environment-uuid/application/application-uuid') + ->and((new RestartLimitReached($serviceApplication))->resource_url)->toBe('https://coolify.test/project/project-uuid/environment/environment-uuid/service/service-uuid') + ->and((new RestartLimitReached($database))->resource_url)->toBe('https://coolify.test/project/project-uuid/environment/environment-uuid/database/database-uuid'); }); it('uses the resolved environment project name in Slack restart limit notifications', function () { From 85ec70643f4a5960b5ccce694b8de7b5425a563c Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:48:16 +0200 Subject: [PATCH 11/48] refactor(notifications): build scheduled task links from the instance url - replace the taskLink() helpers on Application and Service with links built from base_url() in the task notifications --- app/Models/Application.php | 26 ------------------ app/Models/Service.php | 27 ------------------- .../ScheduledTask/TaskFailed.php | 9 ++++--- .../ScheduledTask/TaskSuccess.php | 9 ++++--- 4 files changed, 10 insertions(+), 61 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index f8eb75a5ab..6bfbb6de75 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -621,32 +621,6 @@ class Application extends BaseModel && $this->restart_limit_reached === true; } - public function taskLink($task_uuid) - { - if (data_get($this, 'environment.project.uuid')) { - $route = route('project.application.scheduled-tasks', [ - 'project_uuid' => data_get($this, 'environment.project.uuid'), - 'environment_uuid' => data_get($this, 'environment.uuid'), - 'application_uuid' => data_get($this, 'uuid'), - 'task_uuid' => $task_uuid, - ]); - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - return $url->__toString(); - } - - return $route; - } - - return null; - } - public function settings() { return $this->hasOne(ApplicationSetting::class); diff --git a/app/Models/Service.php b/app/Models/Service.php index 66c67ca8de..1e6a33ad69 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -15,7 +15,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; use Spatie\Activitylog\Models\Activity; -use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; #[OA\Schema( @@ -1463,32 +1462,6 @@ class Service extends BaseModel return null; } - public function taskLink($task_uuid) - { - if (data_get($this, 'environment.project.uuid')) { - $route = route('project.service.scheduled-tasks', [ - 'project_uuid' => data_get($this, 'environment.project.uuid'), - 'environment_uuid' => data_get($this, 'environment.uuid'), - 'service_uuid' => data_get($this, 'uuid'), - 'task_uuid' => $task_uuid, - ]); - $settings = InstanceSettings::get(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - return $url->__toString(); - } - - return $route; - } - - return null; - } - public function documentation() { $services = get_service_templates(); diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index bd060112ae..2ca3874ba0 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -2,6 +2,7 @@ namespace App\Notifications\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; @@ -16,10 +17,10 @@ class TaskFailed extends CustomEmailNotification public function __construct(public ScheduledTask $task, public string $output) { $this->onQueue('high'); - if ($task->application) { - $this->url = $task->application->taskLink($task->uuid); - } elseif ($task->service) { - $this->url = $task->service->taskLink($task->uuid); + $resource = $task->application ?? $task->service; + if ($resource) { + $type = $resource instanceof Application ? 'application' : 'service'; + $this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}"; } } diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 58c959bd8d..2978eaed32 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -2,6 +2,7 @@ namespace App\Notifications\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; @@ -16,10 +17,10 @@ class TaskSuccess extends CustomEmailNotification public function __construct(public ScheduledTask $task, public string $output) { $this->onQueue('high'); - if ($task->application) { - $this->url = $task->application->taskLink($task->uuid); - } elseif ($task->service) { - $this->url = $task->service->taskLink($task->uuid); + $resource = $task->application ?? $task->service; + if ($resource) { + $type = $resource instanceof Application ? 'application' : 'service'; + $this->url = base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/{$type}/{$resource->uuid}/tasks/{$task->uuid}"; } } From 0eb9d5e5a8952552d8a81bb8472bf4207035776d Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:07:26 +0200 Subject: [PATCH 12/48] fix(notifications): urls for ssl renewal notifications --- app/Jobs/RegenerateSslCertJob.php | 5 ++- .../SslExpirationNotification.php | 37 ++----------------- 2 files changed, 7 insertions(+), 35 deletions(-) diff --git a/app/Jobs/RegenerateSslCertJob.php b/app/Jobs/RegenerateSslCertJob.php index 6f49cf30be..ed2d1c4546 100644 --- a/app/Jobs/RegenerateSslCertJob.php +++ b/app/Jobs/RegenerateSslCertJob.php @@ -66,7 +66,10 @@ class RegenerateSslCertJob implements ShouldBeEncrypted, ShouldQueue caCert: $caCert->ssl_certificate, caKey: $caCert->ssl_private_key, ); - $regenerated->push($certificate); + $resource = $certificate->database; + if ($resource) { + $regenerated->push($resource); + } } catch (\Exception $e) { Log::error('Failed to regenerate SSL certificate: '.$e->getMessage()); } diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 78e1e8be9c..023031ddd7 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -7,7 +7,6 @@ use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Collection; -use Spatie\Url\Url; class SslExpirationNotification extends CustomEmailNotification { @@ -19,39 +18,9 @@ class SslExpirationNotification extends CustomEmailNotification { $this->onQueue('high'); $this->resources = collect($resources); - - // Collect URLs for each resource - $this->resources->each(function ($resource) { - if (data_get($resource, 'environment.project.uuid')) { - $routeName = match ($resource->type()) { - 'application' => 'project.application.configuration', - 'database' => 'project.database.configuration', - 'service' => 'project.service.configuration', - default => null - }; - - if ($routeName) { - $route = route($routeName, [ - 'project_uuid' => data_get($resource, 'environment.project.uuid'), - 'environment_uuid' => data_get($resource, 'environment.uuid'), - $resource->type().'_uuid' => data_get($resource, 'uuid'), - ]); - - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $url = Url::fromString($route); - $url = $url->withPort(null); - $fqdn = data_get($settings, 'fqdn'); - $fqdn = str_replace(['http://', 'https://'], '', $fqdn); - $url = $url->withHost($fqdn); - - $this->urls[$resource->name] = $url->__toString(); - } else { - $this->urls[$resource->name] = $route; - } - } - } - }); + $this->urls = $this->resources->mapWithKeys(fn ($resource) => [ + $resource->name => base_url().'/project/'.data_get($resource, 'environment.project.uuid').'/environment/'.data_get($resource, 'environment.uuid')."/database/{$resource->uuid}", + ])->all(); } public function via(object $notifiable): array From 94995c2a0e8d60ee4adbcb71aab0ff9ecf5855d2 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:08:08 +0200 Subject: [PATCH 13/48] fix(deployments): build deployment log links from the instance url --- app/Jobs/ApplicationDeploymentJob.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 88036f5191..01a365a0c5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2362,12 +2362,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue destination: $destination, no_questions_asked: true, ); - $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [ - 'project_uuid' => data_get($this->application, 'environment.project.uuid'), - 'application_uuid' => data_get($this->application, 'uuid'), - 'deployment_uuid' => $deployment_uuid, - 'environment_uuid' => data_get($this->application, 'environment.uuid'), - ])); + $deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}"; + $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}"); } } From 51cab061be1cda01b9b0708979f7e6a51abd333f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:19:21 +0200 Subject: [PATCH 14/48] fix(domains): sync Docker Compose domains for noindex settings --- app/Traits/HasNoindexDomains.php | 11 ++++++++++- tests/Feature/ApplicationDomainsTest.php | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/Traits/HasNoindexDomains.php b/app/Traits/HasNoindexDomains.php index 7afe0d4f25..f3858cc61d 100644 --- a/app/Traits/HasNoindexDomains.php +++ b/app/Traits/HasNoindexDomains.php @@ -58,7 +58,16 @@ trait HasNoindexDomains private function currentDomains(): Collection { - return collect(ValidationPatterns::applicationDomainList($this->fqdn)) + $domains = collect(ValidationPatterns::applicationDomainList($this->fqdn)); + $composeDomains = json_decode((string) ($this->getAttributes()['docker_compose_domains'] ?? null), true); + + if (is_array($composeDomains)) { + foreach ($composeDomains as $entry) { + $domains->push(...ValidationPatterns::applicationDomainList(composeDomainEntryString($entry))); + } + } + + return $domains ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)); } diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 19dca79661..a13b5fdbfd 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2066,6 +2066,28 @@ it('updates search engine indexing from the domains view', function () { ->toBe(['https://staging.example.com']); }); +it('updates search engine indexing for a git docker compose domain', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://compose.example.com'], + ]), + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('toggleNoindexDomain', 'https://compose.example.com', 'noindex') + ->assertDispatched('configurationChanged') + ->assertDispatched('success'); + + expect($this->application->refresh()->noindexDomains()->all()) + ->toBe(['https://compose.example.com']); + + $component->call('toggleNoindexDomain', 'https://compose.example.com', 'index'); + + expect($this->application->refresh()->noindexDomains()->all())->toBe([]); +}); + it('keeps noindex domains when normalizing a custom domain port', function () { $this->application->update([ 'fqdn' => 'https://staging.example.com:8080', From 8dc35e2b5fe958086dcba6acca79847c8efcf9a3 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:27:24 +0200 Subject: [PATCH 15/48] fix: TypeError when adding a new scheduled task --- app/Livewire/Project/Shared/ScheduledTask/Add.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index f170a0a6f0..717007bc04 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -102,7 +102,7 @@ class Add extends Component } } - private function saveScheduledTask(): mixed + private function saveScheduledTask(): void { try { $task = new ScheduledTask; @@ -128,7 +128,7 @@ class Add extends Component $this->dispatch('refreshTasks'); $this->dispatch('success', 'Scheduled task added.'); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } From 053b030c4280471d2b3867fa103da42575e4f10e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:42:26 +0200 Subject: [PATCH 16/48] fix: remove database restart limits and clear stale Traefik state Drop restart-limit fields and enforcement from database resources, clear cached Traefik version data when proxies change, and show missing service environment variables from disabled deploy actions. --- app/Actions/Database/StartDatabase.php | 6 +- app/Actions/Database/StopDatabase.php | 6 +- app/Actions/Docker/GetContainersStatus.php | 20 ++--- app/Actions/Service/StartService.php | 1 - app/Actions/Service/StopService.php | 1 - .../Service/StopServiceApplication.php | 2 +- app/Jobs/CheckTraefikVersionForServerJob.php | 12 ++- app/Jobs/CheckTraefikVersionJob.php | 14 ++++ app/Jobs/PushServerUpdateJob.php | 25 ++---- app/Livewire/Server/Proxy.php | 2 + app/Models/Server.php | 2 + app/Models/ServiceDatabase.php | 3 +- app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- ...7_remove_restart_limits_from_databases.php | 56 +++++++++++++ .../restart-limit-warning.blade.php | 2 +- .../project/service/heading.blade.php | 53 +++++++++--- .../Feature/AllResourceRestartLimitsTest.php | 84 ++++++++++++++----- .../ResourceHeadingUnifiedNavbarTest.php | 26 ++++-- tests/Feature/TraefikVersionStateTest.php | 82 +++++++++++++++++- tests/Unit/StopActionsPersistStatusTest.php | 2 +- 27 files changed, 326 insertions(+), 97 deletions(-) create mode 100644 database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 7735c77d88..cd7e083286 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -28,7 +28,11 @@ class StartDatabase if (! $server->isFunctional()) { return 'Server is not functional'; } - $database->resetRestartLimit(); + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); switch ($database->getMorphClass()) { case StandalonePostgresql::class: $activity = StartPostgresql::run($database); diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index f3c591acfc..8005311b5b 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -32,7 +32,11 @@ class StopDatabase // Reset restart tracking when database is manually stopped $database->update(['status' => 'exited']); if ($resetRestartCount) { - $database->resetRestartLimit(); + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); } if ($dockerCleanup) { diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index be098e481c..c69bd1855f 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -5,7 +5,6 @@ namespace App\Actions\Docker; use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; -use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Service\StopServiceApplication; use App\Actions\Shared\ComplexStatusCheck; @@ -249,9 +248,12 @@ class GetContainersStatus $database->update($updateData); - if ($database->trackRestartCount((int) $restartCount)) { - StopDatabase::dispatch($database, false, false, false); - $database->team()?->notify(new ApplicationRestartLimitReached($database)); + if ($restartCount > ($database->restart_count ?? 0)) { + $database->update([ + 'restart_count' => (int) $restartCount, + 'last_restart_at' => now(), + 'last_restart_type' => 'crash', + ]); } if ($isPublic) { @@ -357,7 +359,9 @@ class GetContainersStatus continue; } - if (! $exitedService->stoppedAfterRestartLimit()) { + if ($exitedService instanceof ServiceDatabase) { + $exitedService->update(['status' => 'exited']); + } elseif (! $exitedService->stoppedAfterRestartLimit()) { $exitedService->update([ 'status' => 'exited', 'restart_count' => 0, @@ -424,9 +428,6 @@ class GetContainersStatus $notRunningDatabases = $databases->pluck('id')->diff($foundDatabases); foreach ($notRunningDatabases as $database) { $database = $databases->where('id', $database)->first(); - if ($database->stoppedAfterRestartLimit()) { - continue; - } if (str($database->status)->startsWith('exited')) { continue; } @@ -442,7 +443,6 @@ class GetContainersStatus 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, - 'restart_limit_reached' => false, ]); // Stop proxy if database was public @@ -582,7 +582,7 @@ class GetContainersStatus $restartCount = isset($this->serviceContainerRestartCounts) ? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0) : 0; - if ($subResource->trackRestartCount($restartCount)) { + if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) { StopServiceApplication::dispatch($subResource, false, false); $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 13371d1265..3dc5c98b3f 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -25,7 +25,6 @@ class StartService $service->saveComposeConfigs(); $service->isConfigurationChanged(save: true); $service->applications()->get()->each->resetRestartLimit(); - $service->databases()->get()->each->resetRestartLimit(); $workdir = $service->workdir(); // $commands[] = "cd {$workdir}"; $commands[] = "echo 'Saved configuration files to {$workdir}.'"; diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 341687d0d2..52d9edda19 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -55,7 +55,6 @@ class StopService }); $dbs->each(function ($database): void { $database->update(['status' => 'exited']); - $database->resetRestartLimit(); }); if ($deleteConnectedNetworks) { diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index fa93a78807..11ad337ed6 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -30,7 +30,7 @@ class StopServiceApplication instant_remote_process($commands, $server, throwError: ! $removeContainer); $serviceApplication->update(['status' => 'exited']); - if ($resetRestartCount) { + if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) { $serviceApplication->resetRestartLimit(); } ServiceStatusChanged::dispatch($service->environment->project->team->id); diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 054a739bc6..e56b93c9e5 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Enums\ProxyStatus; +use App\Enums\ProxyTypes; use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; @@ -33,8 +35,13 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue */ public function handle(): void { + $this->server->refresh(); $this->clearOutdatedInfo(); + if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) { + return; + } + // Detect current version (makes SSH call) $currentVersion = getTraefikVersionFromDockerCompose($this->server); @@ -116,7 +123,10 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue private function clearOutdatedInfo(): void { - $this->server->update(['traefik_outdated_info' => null]); + $this->server->update([ + 'detected_traefik_version' => null, + 'traefik_outdated_info' => null, + ]); } /** diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index ac94aa23f5..0a9eeba005 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -19,6 +19,20 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + Server::query() + ->where(function ($query) { + $query->whereNull('proxy') + ->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value); + }) + ->where(function ($query) { + $query->whereNotNull('detected_traefik_version') + ->orWhereNotNull('traefik_outdated_info'); + }) + ->update([ + 'detected_traefik_version' => null, + 'traefik_outdated_info' => null, + ]); + // Load versions from cached data $traefikVersions = get_traefik_versions(); diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 0e73ee41b2..ef83d19446 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -5,7 +5,6 @@ namespace App\Jobs; use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; -use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; @@ -483,7 +482,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced ]) ->with([ 'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', - 'databases:id,service_id,status,last_online_at,is_public,name,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', + 'databases:id,service_id,status,last_online_at,is_public,name', ]) ->get(); } @@ -506,8 +505,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'restart_count', 'last_restart_at', 'last_restart_type', - 'max_restart_count', - 'restart_limit_reached', ]; return collect([ @@ -675,7 +672,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } $restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0; - if ($subResource->trackRestartCount($restartCount)) { + if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) { StopServiceApplication::dispatch($subResource, false, false); $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); @@ -821,11 +818,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $database->status = $containerStatus; $database->save(); } - if (is_numeric($restartCount) && $database->trackRestartCount((int) $restartCount)) { - StopDatabase::dispatch($database, false, false, false); - $database->team()?->notify(new ApplicationRestartLimitReached($database)); - - return; + if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) { + $database->update([ + 'restart_count' => (int) $restartCount, + 'last_restart_at' => now(), + 'last_restart_type' => 'crash', + ]); } if (! $this->isCompleteSnapshot()) { return; @@ -883,16 +881,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $notFoundDatabaseUuids->each(function ($databaseUuid) { $database = $this->databasesByUuid->get($databaseUuid); if ($database) { - if ($database->stoppedAfterRestartLimit()) { - return; - } if (! str($database->status)->startsWith('exited')) { $database->update([ 'status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, - 'restart_limit_reached' => false, ]); } if ($database->is_public) { @@ -918,9 +912,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Batch update service databases if ($notFoundServiceDatabaseIds->isNotEmpty()) { ServiceDatabase::whereIn('id', $notFoundServiceDatabaseIds) - ->where('restart_limit_reached', false) ->where('status', '!=', 'exited') - ->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]); + ->update(['status' => 'exited']); } } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 68cb52a926..296fd4da5d 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -106,6 +106,8 @@ class Proxy extends Component try { $this->authorize('update', $this->server); $this->server->proxy = null; + $this->server->detected_traefik_version = null; + $this->server->traefik_outdated_info = null; $this->server->save(); $this->dispatch('reloadWindow'); diff --git a/app/Models/Server.php b/app/Models/Server.php index f7a4bf20c0..dccbed15ed 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1812,6 +1812,8 @@ $siteAddress { $this->proxy->set('last_saved_proxy_configuration', null); $this->proxy->set('last_saved_settings', null); $this->proxy->set('last_applied_settings', null); + $this->detected_traefik_version = null; + $this->traefik_outdated_info = null; $this->save(); if ($this->proxySet()) { if ($async) { diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index c932791a78..603d11a7f3 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -2,13 +2,12 @@ namespace App\Models; -use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class ServiceDatabase extends BaseModel { - use HasFactory, HasRestartLimit, SoftDeletes; + use HasFactory, SoftDeletes; protected $fillable = [ 'service_id', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index ee8558bb0a..7ca45cc3b7 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index c728419550..769d9f00c4 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 9c4e4aa664..15a1fe2f82 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 4eada8a008..378d36395d 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index d0a68d4e71..1010ca5f37 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 4f9e737193..90828bf012 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 6027288552..e7db812858 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 8a74824be8..3262611903 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; -use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php b/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php new file mode 100644 index 0000000000..710578fd92 --- /dev/null +++ b/database/migrations/2026_09_04_132827_remove_restart_limits_from_databases.php @@ -0,0 +1,56 @@ +dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } + + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } + + public function down(): void + { + foreach (self::STANDALONE_DATABASE_TABLES as $tableName) { + Schema::table($tableName, function (Blueprint $table) { + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + Schema::table('service_databases', function (Blueprint $table) { + $table->integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } +}; diff --git a/resources/views/components/application/restart-limit-warning.blade.php b/resources/views/components/application/restart-limit-warning.blade.php index 3d08c803d5..5a35623fc4 100644 --- a/resources/views/components/application/restart-limit-warning.blade.php +++ b/resources/views/components/application/restart-limit-warning.blade.php @@ -1,6 +1,6 @@ @props(['application']) -@if ($application->stoppedAfterRestartLimit()) +@if (method_exists($application, 'stoppedAfterRestartLimit') && $application->stoppedAfterRestartLimit()) @php($restartLimit = method_exists($application, 'restartLimitMaximum') ? $application->restartLimitMaximum() : ($application->max_restart_count ?? 0)) @php($displayRestartCount = max($application->restart_count ?? 0, $restartLimit)) - Deploy - Fill required variables first - + @endcan - - - @endif
@@ -287,10 +300,26 @@ @endcan @else - - - + @can('deploy', $service) +
+ + +
+ @endcan @endif diff --git a/tests/Feature/AllResourceRestartLimitsTest.php b/tests/Feature/AllResourceRestartLimitsTest.php index 8418b552cd..7da82f7d65 100644 --- a/tests/Feature/AllResourceRestartLimitsTest.php +++ b/tests/Feature/AllResourceRestartLimitsTest.php @@ -1,5 +1,8 @@ toContain(HasRestartLimit::class); $resource = new $modelClass; @@ -34,17 +38,8 @@ it('gives every independently runnable non-application resource restart limit st 'last_restart_at' => 'datetime', ]); })->with([ - ApplicationPreview::class, - ServiceApplication::class, - ServiceDatabase::class, - StandaloneClickhouse::class, - StandaloneDragonfly::class, - StandaloneKeydb::class, - StandaloneMariadb::class, - StandaloneMongodb::class, - StandaloneMysql::class, - StandalonePostgresql::class, - StandaloneRedis::class, + [ApplicationPreview::class], + [ServiceApplication::class], ]); it('collects restart counts for preview and service containers from both status sources', function () { @@ -157,7 +152,7 @@ it('imports the application model used when claiming a restart limit', function ->toContain('Application::query()'); }); -it('adds restart limit columns to previews services and standalone databases', function () { +it('limits restarts only for applications', function () { $migrations = collect(glob(database_path('migrations/*.php'))) ->map(fn (string $path): string => file_get_contents($path)) ->implode("\n"); @@ -165,19 +160,62 @@ it('adds restart limit columns to previews services and standalone databases', f expect($migrations) ->toContain("'application_previews'") ->toContain("'service_applications'") - ->toContain("'service_databases'") ->toContain("'max_restart_count'") - ->toContain("'restart_limit_reached'"); + ->toContain("'restart_limit_reached'") + ->toContain("dropColumn(['max_restart_count', 'restart_limit_reached'])"); - $restartLimitMigrations = collect(glob(database_path('migrations/*_add_restart_limit_to_*.php'))); + $databaseModels = [ + ServiceDatabase::class, + StandalonePostgresql::class, + StandaloneRedis::class, + StandaloneMongodb::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, + ]; - expect($restartLimitMigrations)->toHaveCount(11); - expect($restartLimitMigrations->map( - fn (string $path): string => substr(basename($path), 0, 17) - )->unique())->toHaveCount(11); - $restartLimitMigrations->each(function (string $path): void { - expect(file_get_contents($path))->not->toContain('foreach ('); - }); + foreach ($databaseModels as $databaseModel) { + expect(class_uses_recursive($databaseModel))->not->toContain(HasRestartLimit::class); + } + + foreach ([ + 'service_databases', + 'standalone_postgresqls', + 'standalone_redis', + 'standalone_mongodbs', + 'standalone_mysqls', + 'standalone_mariadbs', + 'standalone_keydbs', + 'standalone_dragonflies', + 'standalone_clickhouses', + ] as $databaseTable) { + expect(Schema::hasColumn($databaseTable, 'max_restart_count'))->toBeFalse() + ->and(Schema::hasColumn($databaseTable, 'restart_limit_reached'))->toBeFalse(); + } + + foreach ([GetContainersStatus::class, PushServerUpdateJob::class] as $statusUpdater) { + $source = file_get_contents((new ReflectionClass($statusUpdater))->getFileName()); + + expect($source) + ->not->toContain('$database->trackRestartCount') + ->not->toContain('$database->stoppedAfterRestartLimit()'); + } + + $stopServiceResource = file_get_contents((new ReflectionClass(StopServiceApplication::class))->getFileName()); + + expect($stopServiceResource) + ->toContain('$resetRestartCount && $serviceApplication instanceof ServiceApplication'); +}); + +it('does not render restart limit warnings for service databases', function () { + $html = Blade::render( + '', + ['database' => new ServiceDatabase], + ); + + expect(trim($html))->toBeEmpty(); }); it('atomically claims a resource restart limit once and can reset it', function () { diff --git a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php index ce69f26ef7..f7951e93a0 100644 --- a/tests/Feature/ResourceHeadingUnifiedNavbarTest.php +++ b/tests/Feature/ResourceHeadingUnifiedNavbarTest.php @@ -82,21 +82,31 @@ it('docks desktop resource actions in the top bar instead of floating over conte } }); -it('links the service header missing variables warning to environment variables', function () { +it('shows disabled deploy actions when service variables are missing', function () { $heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')); $mobileActions = str($heading) ->after('
') ->before("@teleport('#resource-action-hud-slot')") ->toString(); + $desktopActions = str($heading) + ->after("@teleport('#resource-action-hud-slot')") + ->before('@endteleport') + ->toString(); - expect($heading) - ->toContain("route('project.service.environment-variables'") - ->toContain('Required variables missing') + expect($mobileActions) + ->toContain('id="service-mobile-actions"') + ->toContain('aria-disabled="true"') + ->toContain('Deploy') + ->toContain('missing required env vars') ->toContain('href="{{ $environmentVariablesUrl }}"') - ->and($mobileActions) - ->toContain('Fill required variables first') - ->toContain('disabled') - ->toContain('Deploy'); + ->toContain('underline') + ->and($desktopActions) + ->toContain('id="service-desktop-actions"') + ->toContain('aria-disabled="true"') + ->toContain('Deploy') + ->toContain('missing required env vars') + ->toContain('href="{{ $environmentVariablesUrl }}"') + ->toContain('underline'); }); it('places the account menu beside the desktop sidebar toggle while retaining it on mobile', function () { diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php index 3dad5423e5..de3cf8e17c 100644 --- a/tests/Feature/TraefikVersionStateTest.php +++ b/tests/Feature/TraefikVersionStateTest.php @@ -1,12 +1,16 @@ hasCurrentTraefikOutdatedInfo())->toBeTrue(); }); -it('clears stale outdated information before detecting the current version', function () { +it('clears stale Traefik version state before detecting the current version', function () { $team = Team::factory()->create(); $server = Server::factory()->create([ 'team_id' => $team->id, @@ -204,6 +208,80 @@ it('clears stale outdated information before detecting the current version', fun $server->refresh(); - expect($server->detected_traefik_version)->toBe('3.6.23') + expect($server->detected_traefik_version)->toBeNull() ->and($server->traefik_outdated_info)->toBeNull(); }); + +it('clears Traefik version state when the proxy changes', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'running', + ], + 'detected_traefik_version' => '3.6.23', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + ], + ]); + + $server->changeProxy(ProxyTypes::NONE->value); + + expect($server->refresh()->detected_traefik_version)->toBeNull() + ->and($server->traefik_outdated_info)->toBeNull(); +}); + +it('cleans stale Traefik version state while selecting servers to check', function () { + Bus::fake(); + Cache::put('coolify:versions:all', [ + 'traefik' => ['v3.7' => '3.7.8'], + ]); + + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => [ + 'type' => ProxyTypes::NONE->value, + 'status' => 'exited', + ], + 'detected_traefik_version' => '3.6.23', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + ], + ]); + + (new CheckTraefikVersionJob)->handle(); + + expect($server->refresh()->detected_traefik_version)->toBeNull() + ->and($server->traefik_outdated_info)->toBeNull(); +}); + +it('does not inspect a server after its Traefik proxy has been disabled', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => [ + 'type' => ProxyTypes::NONE->value, + 'status' => 'exited', + ], + 'detected_traefik_version' => '3.6.23', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + ], + ]); + Event::fake(); + + (new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.8']))->handle(); + + expect($server->refresh()->detected_traefik_version)->toBeNull() + ->and($server->traefik_outdated_info)->toBeNull(); + + Event::assertNotDispatched(ProxyStatusChangedUI::class); +}); diff --git a/tests/Unit/StopActionsPersistStatusTest.php b/tests/Unit/StopActionsPersistStatusTest.php index 35b6052dd3..67a7a8c6f9 100644 --- a/tests/Unit/StopActionsPersistStatusTest.php +++ b/tests/Unit/StopActionsPersistStatusTest.php @@ -21,7 +21,7 @@ it('persists exited status for all children when stopping a service', function ( ->toContain("\$application->update(['status' => 'exited']);") ->toContain('$application->resetRestartLimit();') ->toContain("\$database->update(['status' => 'exited']);") - ->toContain('$database->resetRestartLimit();'); + ->not->toContain('$database->resetRestartLimit();'); }); it('persists exited status when stopping an individual service resource', function () { From 2ebbc5113ec8ab4e329e3c14daf9cacc3b0d5155 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:44:18 +0200 Subject: [PATCH 17/48] fix: return null for invalid repository URLs --- app/Models/Application.php | 6 +++++- tests/Unit/ApplicationGitCommitLinkTest.php | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 6bfbb6de75..7f7ce5ea1f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -714,7 +714,7 @@ class Application extends BaseModel ); } - public function gitCommitLink($link): string + public function gitCommitLink($link): ?string { if (! is_null(data_get($this, 'source.html_url')) && ! is_null(data_get($this, 'git_repository')) && ! is_null(data_get($this, 'git_branch'))) { if (str($this->source->html_url)->contains('bitbucket')) { @@ -731,6 +731,10 @@ class Application extends BaseModel $git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH); } + if (! filter_var($git_repository, FILTER_VALIDATE_URL)) { + return null; + } + $url = Url::fromString(Str::replaceEnd('.git', '', $git_repository)); $url = $url->withUserInfo(''); $commitPath = str($git_repository)->contains('bitbucket') ? 'commits' : 'commit'; diff --git a/tests/Unit/ApplicationGitCommitLinkTest.php b/tests/Unit/ApplicationGitCommitLinkTest.php index e1a15e7149..378384fe8f 100644 --- a/tests/Unit/ApplicationGitCommitLinkTest.php +++ b/tests/Unit/ApplicationGitCommitLinkTest.php @@ -26,3 +26,14 @@ it('generates commit links for direct repository remotes', function (string $rep 'https://bitbucket.org/coollabsio/coolify/commits/1234567890abcdef', ], ]); + +it('does not generate commit links from incomplete repository URLs', function (string $repository) { + $application = new Application; + $application->setRelation('source', null); + $application->git_repository = $repository; + + expect($application->gitCommitLink('1234567890abcdef'))->toBeNull(); +})->with([ + 'missing host' => 'https://', + 'missing scheme' => 'github.com/coollabsio/coolify', +]); From 89814743c8233fa856d58fcb72a2f04ecc9e600c Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:45:55 +0200 Subject: [PATCH 18/48] fix(applications): TypeError when stopping an application --- app/Actions/Application/StopApplication.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 3feb5117d8..fcf1e1d0fe 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -78,5 +78,7 @@ class StopApplication $application->update($status); ServiceStatusChanged::dispatch($application->environment->project->team->id); + + return null; } } From aafef73aa651366d4ec14f8fa2a1d426817eb105 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:53:49 +0200 Subject: [PATCH 19/48] fix(docker): match helper containers at any registry depth --- app/Jobs/CleanupHelperContainersJob.php | 2 +- tests/Unit/CleanupHelperContainersJobTest.php | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index 425dd2591c..52b4064feb 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -21,7 +21,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S private static function helperContainersCommand(): string { - return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("^([^/]+/)?coollabsio/coolify-helper(:|@)")))\''; + return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\''; } public function handle(): void diff --git a/tests/Unit/CleanupHelperContainersJobTest.php b/tests/Unit/CleanupHelperContainersJobTest.php index a649985d28..bd3e2e175d 100644 --- a/tests/Unit/CleanupHelperContainersJobTest.php +++ b/tests/Unit/CleanupHelperContainersJobTest.php @@ -13,6 +13,11 @@ it('matches helper image references without matching similarly named images', fu 'docker.io/coollabsio/coolify-helper:1.0.16', 'ghcr.io/coollabsio/coolify-helper@sha256:abc', 'registry.example/team/coollabsio/coolify-helper:latest', + 'coollabsio/coolify:latest', + 'coollabsio/coolify:4.3.12', + 'coollabsio/coolify-realtime:1.0.10', + 'coolify-helper:latest', + 'someone/coolify-helper:1.0.16', 'evil/coollabsio/coolify-helper-copy:latest', 'coollabsio/not-coolify-helper:latest', ]; @@ -31,7 +36,7 @@ it('matches helper image references without matching similarly named images', fu $process->mustRun(); expect(array_column(json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), 'Image')) - ->toBe(array_slice($images, 0, 3)); + ->toBe(array_slice($images, 0, 4)); } finally { unlink($docker); rmdir($directory); From 92c0035002d977cea4925984999e1895766910fd Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:34:40 +0200 Subject: [PATCH 20/48] ci: sync main to next once a day instead of on every push merging main into next after every commit clutters the next branch with too many merge commits. --- .github/workflows/sync-main-to-next.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 595a21e799..b9b8b361f2 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -1,8 +1,8 @@ name: Sync main to next on: - push: - branches: [main] + schedule: + - cron: '0 3 * * *' workflow_dispatch: permissions: From a04c2ecb442c3ed8a4eac60a6bb9e3413111d13c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:38:24 +0200 Subject: [PATCH 21/48] fix(docker): preserve restart policies when stopping containers --- app/Actions/Application/StopApplication.php | 2 -- app/Actions/Application/StopApplicationPreview.php | 2 -- app/Actions/Database/StopDatabase.php | 2 -- app/Actions/Service/StopServiceApplication.php | 5 +---- .../ApplicationStoppedAfterRestartLimitTest.php | 2 +- tests/Unit/StopActionsPersistStatusTest.php | 11 +++++++++++ 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index fcf1e1d0fe..12ac569009 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -44,8 +44,6 @@ class StopApplication $commands = [dockerStopCommand($timeout, $containerName, $server)]; if ($removeContainers) { $commands[] = "docker rm -f $containerName"; - } else { - array_unshift($commands, "docker update --restart=no $containerName"); } instant_remote_process(command: $commands, server: $server, throwError: false); diff --git a/app/Actions/Application/StopApplicationPreview.php b/app/Actions/Application/StopApplicationPreview.php index af5f3fc0f0..8bb3a3dc08 100644 --- a/app/Actions/Application/StopApplicationPreview.php +++ b/app/Actions/Application/StopApplicationPreview.php @@ -20,8 +20,6 @@ class StopApplicationPreview $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)]; if ($removeContainer) { $commands[] = "docker rm -f $containerName"; - } else { - array_unshift($commands, "docker update --restart=no $containerName"); } instant_remote_process($commands, $server, false); } diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index 8005311b5b..d3c6fafc4d 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -62,8 +62,6 @@ class StopDatabase $commands = [dockerStopCommand($timeout, $containerName, $server)]; if ($removeContainer) { $commands[] = "docker rm -f $containerName"; - } else { - array_unshift($commands, "docker update --restart=no $containerName"); } instant_remote_process(command: $commands, server: $server, throwError: false); } diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index 11ad337ed6..1b53472656 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -22,10 +22,7 @@ class StopServiceApplication if ($removeContainer) { $commands = ["docker rm -f {$containerName}"]; } else { - $commands = [ - "docker update --restart=no {$containerName}", - "docker stop {$containerName}", - ]; + $commands = ["docker stop {$containerName}"]; } instant_remote_process($commands, $server, throwError: ! $removeContainer); diff --git a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php index 58ebb8c703..f5875adb2c 100644 --- a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php +++ b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php @@ -126,7 +126,7 @@ it('can stop an application without removing its containers', function () { expect($removeContainers)->not->toBeNull() ->and($removeContainers->getDefaultValue())->toBeTrue() - ->and($action)->toContain('docker update --restart=no') + ->and($action)->not->toContain('docker update --restart=no') ->and($action)->toContain('if ($removeContainers)'); }); diff --git a/tests/Unit/StopActionsPersistStatusTest.php b/tests/Unit/StopActionsPersistStatusTest.php index 67a7a8c6f9..a6d8d2fc1e 100644 --- a/tests/Unit/StopActionsPersistStatusTest.php +++ b/tests/Unit/StopActionsPersistStatusTest.php @@ -6,6 +6,17 @@ it('persists exited status when stopping standalone databases', function () { expect($action)->toContain("'status' => 'exited'"); }); +it('does not change Docker restart policies when retaining stopped containers', function (string $actionPath) { + $action = file_get_contents(__DIR__.'/../../'.$actionPath); + + expect($action)->not->toContain('docker update --restart=no'); +})->with([ + 'applications' => 'app/Actions/Application/StopApplication.php', + 'application previews' => 'app/Actions/Application/StopApplicationPreview.php', + 'service applications' => 'app/Actions/Service/StopServiceApplication.php', + 'standalone databases' => 'app/Actions/Database/StopDatabase.php', +]); + it('persists exited status for every full application stop path', function () { $action = file_get_contents(__DIR__.'/../../app/Actions/Application/StopApplication.php'); From 851a902346aa1f1b847ce9dd4fa79d2a2513f941 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:47:12 +0200 Subject: [PATCH 22/48] fix(domains): preserve removed compose service domains --- bootstrap/helpers/domains.php | 20 ++++++++++++++++++ bootstrap/helpers/parsers.php | 7 ++---- tests/Feature/ApplicationDomainsTest.php | 27 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 4e4ad73e6f..28ff41b3df 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -443,6 +443,26 @@ function getComposeServiceDomainString(array|Collection $domains, string $servic return $matches[0]['domain']; } +/** + * Determine whether a compose service already has a domain-map entry, including + * an explicitly empty entry left when a user removes its generated domain. + * + * @param array|Collection $domains + */ +function hasComposeServiceDomainEntry(array|Collection $domains, string $serviceName): bool +{ + $normalized = normalizeComposeServiceName($serviceName); + + foreach (collect($domains)->keys() as $key) { + $key = (string) $key; + if ($key === $serviceName || normalizeComposeServiceName($key) === $normalized) { + return true; + } + } + + return false; +} + function composeDomainEntryString(mixed $entry): ?string { if (is_object($entry)) { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 590b68d162..03ebf5af7a 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -525,8 +525,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services)); if ($originalServiceName !== null) { $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; - $domainExists = getComposeServiceDomainString($domains, $originalServiceName); - if (is_null($domainExists)) { + if (! hasComposeServiceDomainEntry($domains, $originalServiceName)) { $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value(); $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid"); if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) { @@ -648,12 +647,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Only add domain if the service exists if ($composeServiceName !== null) { $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; - $domainExists = getComposeServiceDomainString($domains, $composeServiceName); - // Update domain using URL with port if applicable $domainValue = $port ? $urlWithPort : $url; - if (is_null($domainExists)) { + if (! hasComposeServiceDomainEntry($domains, $composeServiceName)) { $resource->docker_compose_domains = json_encode(putComposeServiceDomain( $domains, $composeServiceName, diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index a13b5fdbfd..f2beb22f13 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -111,6 +111,33 @@ it('does not add a single-label hostname as an application domain', function () expect($this->application->fresh()->fqdn)->toBeNull(); }); +it('keeps a compose domain removed when the service declares a magic URL variable', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => <<<'YAML' +services: + web: + image: nginx:alpine + environment: + SERVICE_URL_WEB: /api +YAML, + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://web.example.com/api'], + ]), + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + $domainKey = hash('sha256', 'https://web.example.com/api|web'); + + $component + ->call('removeDomainByKey', $domainKey) + ->assertDispatched('success') + ->assertSet('domainRows', []); + + expect(json_decode($this->application->fresh()->docker_compose_domains, true)) + ->toMatchArray(['web' => ['domain' => null]]); +}); + it('generates a preview domain when the application has no domain', function () { $preview = ApplicationPreview::create([ 'application_id' => $this->application->id, From 5ff21103974a30e107ce046190fb478658a214e7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:01:42 +0200 Subject: [PATCH 23/48] fix(environment-variables): preserve generated Compose variables Exclude protected Compose variables from bulk deletion and developer views, and propagate deletion failures correctly. --- .../Shared/EnvironmentVariable/All.php | 42 +++++++----- .../EnvironmentVariableAsyncLoadTest.php | 66 +++++++++++++++++++ 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index ea8394c1b1..47080cd86b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -818,19 +818,21 @@ class All extends Component { $isMember = auth()->user()?->isMember(); - return $variables->map(function ($item) use ($isMember) { - if ($isMember) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } + return $variables + ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) + ->map(function ($item) use ($isMember) { + if ($isMember) { + return "$item->key=(Hidden, only admins can view)"; + } + if ($item->is_shown_once) { + return "$item->key=(Locked Secret, delete and add again to change)"; + } + if ($item->is_multiline) { + return "$item->key=(Multiline environment variable, edit in normal view)"; + } - return "$item->key=$item->value"; - })->join("\n"); + return "$item->key=$item->value"; + })->join("\n"); } public function switch() @@ -908,8 +910,7 @@ class All extends Component $deletedCount = $this->deleteRemovedVariables(false, $variables); if ($deletedCount > 0) { $changesMade = true; - } elseif ($deletedCount === 0 && $this->resource->environment_variables()->whereNotIn('key', array_keys($variables))->exists()) { - // If we tried to delete but couldn't (due to Docker Compose), mark as error + } elseif ($deletedCount < 0) { $errorOccurred = true; } @@ -926,8 +927,7 @@ class All extends Component $deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables); if ($deletedPreviewCount > 0) { $changesMade = true; - } elseif ($deletedPreviewCount === 0 && $this->resource->environment_variables_preview()->whereNotIn('key', array_keys($previewVariables))->exists()) { - // If we tried to delete but couldn't (due to Docker Compose), mark as error + } elseif ($deletedPreviewCount < 0) { $errorOccurred = true; } @@ -988,6 +988,12 @@ class All extends Component // Get all environment variables that will be deleted $variablesToDelete = $this->resource->$method()->whereNotIn('key', array_keys($variables))->get(); + // Generated Compose variables are managed by Coolify and must survive a bulk + // replacement even when they are omitted from the pasted environment file. + $variablesToDelete = $variablesToDelete->reject( + fn (EnvironmentVariable $environmentVariable): bool => $this->isProtectedEnvironmentVariable($environmentVariable->key) + ); + // If there are no variables to delete, return 0 if ($variablesToDelete->isEmpty()) { return 0; @@ -1001,13 +1007,13 @@ class All extends Component if ($isUsed) { $this->dispatch('error', "Cannot delete environment variable '{$envVar->key}'

Please remove it from the Docker Compose file first."); - return 0; + return -1; } } } // If we get here, no variables are used in Docker Compose, so we can delete them - $this->resource->$method()->whereNotIn('key', array_keys($variables))->delete(); + $this->resource->$method()->whereKey($variablesToDelete->modelKeys())->delete(); return $variablesToDelete->count(); } diff --git a/tests/Feature/EnvironmentVariableAsyncLoadTest.php b/tests/Feature/EnvironmentVariableAsyncLoadTest.php index 2cbc5537a1..4d83862b6e 100644 --- a/tests/Feature/EnvironmentVariableAsyncLoadTest.php +++ b/tests/Feature/EnvironmentVariableAsyncLoadTest.php @@ -121,3 +121,69 @@ it('is idempotent when loadEnvironmentVariables is called twice', function () { expect($component->instance()->environmentVariables->pluck('key')->all()) ->toContain('API_KEY'); }); + +it('preserves generated compose variables during bulk replacement', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'build_pack' => 'dockercompose', + 'docker_compose' => <<<'YAML' +services: + api: + image: nginx:alpine + environment: + SERVICE_URL_API: /api +YAML, + ]); + + foreach ([ + 'SERVICE_URL_API' => 'https://api.example.com/api', + 'SERVICE_FQDN_API' => 'api.example.com/api', + 'OLD_VARIABLE' => 'remove-me', + ] as $key => $value) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + } + + Livewire::test(All::class, ['resource' => $application]) + ->set('variables', 'NEW_VARIABLE=keep-me') + ->call('submit') + ->assertDispatched('success') + ->assertNotDispatched('error'); + + expect($application->environment_variables()->pluck('value', 'key')->all()) + ->toBe([ + 'SERVICE_URL_API' => 'https://api.example.com/api', + 'SERVICE_FQDN_API' => 'api.example.com/api', + 'NEW_VARIABLE' => 'keep-me', + ]); +}); + +it('hides generated compose variables from developer view', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'build_pack' => 'dockercompose', + ]); + + foreach ([ + 'SERVICE_URL_API' => 'https://api.example.com', + 'SERVICE_FQDN_API' => 'api.example.com', + 'API_URL' => '$SERVICE_URL_API', + ] as $key => $value) { + EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + ]); + } + + $component = Livewire::test(All::class, ['resource' => $application]) + ->call('switch'); + + expect($component->get('variables')) + ->toBe('API_URL=$SERVICE_URL_API'); +}); From dba3d114e5ed2be9a466c030c8cbc2ec58b498d9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:09:53 +0200 Subject: [PATCH 24/48] chore(release): bump Coolify version to 4.3.18 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- tests/Unit/ProductionImageWorkflowTest.php | 4 ++-- versions.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/constants.php b/config/constants.php index 7ad0c4594f..22d5e10118 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.17', + 'version' => env('COOLIFY_VERSION') ?: '4.3.18', 'helper_version' => '1.0.16', 'realtime_version' => '1.0.18', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 0e83c0cf74..97ab3b3a6a 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.17" + "version": "4.3.18" }, "nightly": { "version": "4.4-rc.1" diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 140a6276f2..206cf5f34e 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.17'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.17') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.18'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.18') ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); diff --git a/versions.json b/versions.json index 0e83c0cf74..97ab3b3a6a 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.17" + "version": "4.3.18" }, "nightly": { "version": "4.4-rc.1" From 9c3fb1da396bdd26ece4711f673c46540287039f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:56:08 +0200 Subject: [PATCH 25/48] fix(domains): limit instance addresses to localhost DNS hints --- .../InteractsWithCloudflareDomainConnect.php | 27 +++++---- app/Livewire/Project/Application/Domains.php | 5 ++ app/Livewire/Project/Service/Domains.php | 5 ++ tests/Feature/ApplicationDomainsTest.php | 57 +++++++++++++++++++ tests/Feature/ServiceDomainsTest.php | 18 ++++++ 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php index 44dba0d5e8..6ecea8e96f 100644 --- a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php +++ b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php @@ -209,19 +209,20 @@ trait InteractsWithCloudflareDomainConnect } } - // Prefer instance public IPv6 when the destination IP is IPv4-only (and vice versa). - try { - $settings = instanceSettings(); - $publicV4 = data_get($settings, 'public_ipv4'); - $publicV6 = data_get($settings, 'public_ipv6'); - if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $ipv4 = $publicV4; + if ($this->usesInstanceNetworkAddressesForDnsHints()) { + try { + $settings = instanceSettings(); + $publicV4 = data_get($settings, 'public_ipv4'); + $publicV6 = data_get($settings, 'public_ipv6'); + if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $ipv4 = $publicV4; + } + if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $ipv6 = $publicV6; + } + } catch (\Throwable) { + // } - if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $ipv6 = $publicV6; - } - } catch (\Throwable) { - // } return [$ipv4, $ipv6]; @@ -253,5 +254,7 @@ trait InteractsWithCloudflareDomainConnect return null; } + abstract protected function usesInstanceNetworkAddressesForDnsHints(): bool; + abstract protected function authorizeUpdateForDomainConnect(): void; } diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index d7797935f5..839c61e36a 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -661,6 +661,11 @@ class Domains extends Component $this->authorize('update', $this->application); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->application->destination?->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->application); diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index d5254e093a..4ac2f40b2b 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -459,6 +459,11 @@ class Domains extends Component $this->authorize('update', $this->service); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->service->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->service); diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index f2beb22f13..396cccbfc9 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -716,6 +716,63 @@ it('lists dns entries for domains that still need dns and omits working configur ->not->toContain('app.example.com'); }); +it('does not use instance network addresses for dns entries on a remote server', function () { + InstanceSettings::get()->update([ + 'public_ipv4' => '198.51.100.20', + 'public_ipv6' => '2001:db8::20', + ]); + $this->application->update(['fqdn' => 'https://app.example.com']); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + $records = $component->instance()->dnsRecordHints(); + + expect($records)->toBe([ + [ + 'type' => 'A', + 'name' => 'app.example.com', + 'value' => '203.0.113.10', + ], + ]); +}); + +it('uses instance network addresses for dns entries on the localhost server', function () { + InstanceSettings::get()->update([ + 'public_ipv4' => '198.51.100.20', + 'public_ipv6' => '2001:db8::20', + ]); + $localhost = Server::factory()->create([ + 'id' => 0, + 'team_id' => $this->team->id, + 'private_key_id' => $this->server->private_key_id, + 'ip' => 'localhost', + ]); + $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::forceCreate([ + 'uuid' => (string) Str::uuid(), + 'name' => 'localhost-docker', + 'network' => 'coolify-localhost', + 'server_id' => $localhost->id, + ])); + $this->application->update([ + 'destination_id' => $destination->id, + 'fqdn' => 'https://app.example.com', + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + expect($component->instance()->dnsRecordHints())->toBe([ + [ + 'type' => 'A', + 'name' => 'app.example.com', + 'value' => '198.51.100.20', + ], + [ + 'type' => 'AAAA', + 'name' => 'app.example.com', + 'value' => '2001:db8::20', + ], + ]); +}); + it('shows cloudflare domain connect only on cloud with a key', function () { config([ 'constants.coolify.self_hosted' => false, diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 4f825f8e24..c93a4f1e9d 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -280,6 +280,24 @@ it('lists dns entries for service hosts that still need dns', function () { ->not->toContain('web.example.com'); }); +it('does not use instance network addresses for service dns entries on a remote server', function () { + InstanceSettings::get()->update([ + 'public_ipv4' => '198.51.100.20', + 'public_ipv6' => '2001:db8::20', + ]); + $this->apiApp->update(['fqdn' => 'https://api.example.com']); + + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]); + + expect($component->instance()->dnsRecordHints())->toBe([ + [ + 'type' => 'A', + 'name' => 'api.example.com', + 'value' => '203.0.113.10', + ], + ]); +}); + it('persists a service redirect when its dropdown changes', function () { $this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']); From 3c0e43f482317dd2d5b0db99fc272b563b8c9c9b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:15:06 +0200 Subject: [PATCH 26/48] feat(settings): configure CDN URL for stored images Add a persisted instance setting for S3 image CDN URLs and use it when building image links. Cache profile avatars and project icons with immutable one-year headers. --- .env.development.example | 1 - .env.windows-docker-desktop.example | 1 - .../Controllers/ProfileAvatarController.php | 2 +- .../Controllers/ProjectIconController.php | 5 +++- app/Livewire/Settings/Advanced.php | 5 ++++ app/Models/InstanceSettings.php | 1 + bootstrap/helpers/shared.php | 2 +- config/constants.php | 1 - ...age_cdn_url_to_instance_settings_table.php | 28 +++++++++++++++++++ .../livewire/settings/advanced.blade.php | 6 ++-- tests/Feature/ProfileAvatarTest.php | 9 +++--- tests/Feature/ProjectIconTest.php | 9 +++--- tests/Feature/SettingsAccessListboxTest.php | 28 +++++++++++++++++++ 13 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php diff --git a/.env.development.example b/.env.development.example index 56c17128ce..380f10a446 100644 --- a/.env.development.example +++ b/.env.development.example @@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444 BUNNY_API_KEY= # For asset uploads BUNNY_STORAGE_API_KEY= -AVATAR_CDN_URL= diff --git a/.env.windows-docker-desktop.example b/.env.windows-docker-desktop.example index 626d76ff63..b067b4c5c0 100644 --- a/.env.windows-docker-desktop.example +++ b/.env.windows-docker-desktop.example @@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify PUSHER_APP_ID=coolify PUSHER_APP_KEY=coolify PUSHER_APP_SECRET=coolify -AVATAR_CDN_URL= diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php index 2cf01400e8..f53cef7c51 100644 --- a/app/Http/Controllers/ProfileAvatarController.php +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -14,7 +14,7 @@ class ProfileAvatarController extends Controller return response($contents, 200, [ 'Content-Type' => 'image/jpeg', - 'Cache-Control' => 'private, max-age=300', + 'Cache-Control' => 'private, max-age=31536000, immutable', ]); } } diff --git a/app/Http/Controllers/ProjectIconController.php b/app/Http/Controllers/ProjectIconController.php index fb7ebc8860..d99e9166d0 100644 --- a/app/Http/Controllers/ProjectIconController.php +++ b/app/Http/Controllers/ProjectIconController.php @@ -15,6 +15,9 @@ class ProjectIconController extends Controller abort_if($contents === null, 404); - return response($contents)->header('Content-Type', 'image/jpeg'); + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=31536000, immutable', + ]); } } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index fd5ee616d9..45aff3f3c9 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -53,6 +53,8 @@ class Advanced extends Component public string $avatar_storage = 'local'; + public ?string $image_cdn_url = null; + public array $avatar_storage_options = []; public function rules() @@ -71,6 +73,7 @@ class Advanced extends Component 'webhook_allowed_internal_hosts' => 'nullable|string', 'webhook_allow_localhost' => 'boolean', 'domain_connect_private_key' => 'nullable|string', + 'image_cdn_url' => 'nullable|url|max:255', ]; } @@ -97,6 +100,7 @@ class Advanced extends Component $this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id ? 's3:'.$this->settings->avatar_s3_storage_id : 'local'; + $this->image_cdn_url = $this->settings->image_cdn_url; $this->avatar_storage_options = [ ['value' => 'local', 'label' => 'Local storage'], ...S3Storage::query() @@ -210,6 +214,7 @@ class Advanced extends Component $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; + $this->settings->image_cdn_url = filled($this->image_cdn_url) ? rtrim($this->image_cdn_url, '/') : null; $this->saveAvatarStorageSetting(); $this->settings->save(); $this->dispatch('success', 'Settings updated!'); diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index eb01fa7ada..26aceec354 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -56,6 +56,7 @@ class InstanceSettings extends Model 'webhook_allow_localhost', 'avatar_storage_type', 'avatar_s3_storage_id', + 'image_cdn_url', 'is_dashboard_force_https_enabled', ]; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f9bf44dffa..bc5af1e86e 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -860,7 +860,7 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string return null; } - $baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl(); + $baseUrl = instanceSettings()->image_cdn_url ?: $storage->awsUrl(); return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version; } diff --git a/config/constants.php b/config/constants.php index 22d5e10118..bdcd5d7c9d 100644 --- a/config/constants.php +++ b/config/constants.php @@ -14,7 +14,6 @@ return [ 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), - 'avatar_cdn_url' => env('AVATAR_CDN_URL'), '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://cdn.coollabs.io/coolify/releases.json'), diff --git a/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php new file mode 100644 index 0000000000..1c898dd5da --- /dev/null +++ b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php @@ -0,0 +1,28 @@ +string('image_cdn_url')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('image_cdn_url'); + }); + } +}; diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 94927ba257..4c11cb41d4 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -9,7 +9,7 @@ listboxes (API, MCP, telemetry, …) update the snapshot on the server immediately; without wire:target they briefly flash this bar. --}} + targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key,image_cdn_url" />
@@ -144,9 +144,11 @@ -
+
+
@if (count($avatar_storage_options) === 1) diff --git a/tests/Feature/ProfileAvatarTest.php b/tests/Feature/ProfileAvatarTest.php index 68ced553d5..3512fccea3 100644 --- a/tests/Feature/ProfileAvatarTest.php +++ b/tests/Feature/ProfileAvatarTest.php @@ -72,11 +72,14 @@ it('serves the authenticated users profile picture', function () { $this->withoutMiddleware()->actingAs($user) ->get(route('profile.avatar')) ->assertSuccessful() - ->assertHeader('content-type', 'image/jpeg'); + ->assertHeader('content-type', 'image/jpeg') + ->assertHeader('cache-control', 'immutable, max-age=31536000, private'); }); it('loads an S3 profile picture from the configured CDN', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/'); + InstanceSettings::findOrFail(0)->update([ + 'image_cdn_url' => 'https://avatars.example.com/media', + ]); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -98,7 +101,6 @@ it('loads an S3 profile picture from the configured CDN', function () { }); it('loads an S3 profile picture directly from S3 when the CDN is not configured', function () { - config()->set('constants.coolify.avatar_cdn_url'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -120,7 +122,6 @@ it('loads an S3 profile picture directly from S3 when the CDN is not configured' }); it('does not use an unrelated S3 storage URL for a profile picture', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com'); $storage = S3Storage::query()->create([ 'team_id' => Team::factory()->create()->id, 'name' => 'Unrelated storage', diff --git a/tests/Feature/ProjectIconTest.php b/tests/Feature/ProjectIconTest.php index fc03c9f30e..99a6c0ac5b 100644 --- a/tests/Feature/ProjectIconTest.php +++ b/tests/Feature/ProjectIconTest.php @@ -61,7 +61,8 @@ it('serves a project icon only to a member of its team', function () { $this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid])) ->assertSuccessful() - ->assertHeader('content-type', 'image/jpeg'); + ->assertHeader('content-type', 'image/jpeg') + ->assertHeader('cache-control', 'immutable, max-age=31536000, private'); $otherUser = User::factory()->create(); $otherTeam = Team::factory()->create(); @@ -104,7 +105,9 @@ it('exposes the icon URL on the projects index', function () { }); it('loads an S3 project icon from the configured CDN', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/'); + InstanceSettings::findOrFail(0)->update([ + 'image_cdn_url' => 'https://avatars.example.com/media', + ]); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -127,7 +130,6 @@ it('loads an S3 project icon from the configured CDN', function () { }); it('loads an S3 project icon directly from S3 when the CDN is not configured', function () { - config()->set('constants.coolify.avatar_cdn_url'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, @@ -149,7 +151,6 @@ it('loads an S3 project icon directly from S3 when the CDN is not configured', f }); it('does not use an unusable S3 storage URL for a project icon', function () { - config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com'); Team::factory()->create(['id' => 0]); $storage = S3Storage::query()->create([ 'team_id' => 0, diff --git a/tests/Feature/SettingsAccessListboxTest.php b/tests/Feature/SettingsAccessListboxTest.php index 4ec9dceb9e..c1508bf73b 100644 --- a/tests/Feature/SettingsAccessListboxTest.php +++ b/tests/Feature/SettingsAccessListboxTest.php @@ -29,6 +29,12 @@ test('settings advanced access section always uses listboxes', function () { ->not->toContain('Two-step confirmations enabled'); }); +test('image storage fields use the standard settings field gap', function () { + $contents = file_get_contents(resource_path('views/livewire/settings/advanced.blade.php')); + + expect($contents)->toContain('
'); +}); + test('instance admin can toggle registration via listbox instantSave', function () { $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); @@ -82,6 +88,28 @@ test('instance admin can toggle two-step confirmation via listbox instantSave', expect((bool) $settings->fresh()->disable_two_step_confirmation)->toBeTrue(); }); +test('instance admin can configure the image CDN URL at runtime', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); + $settings = InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(Advanced::class) + ->assertSee('Image CDN URL') + ->set('image_cdn_url', 'https://images.example.com/media/') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($settings->fresh()->image_cdn_url)->toBe('https://images.example.com/media'); +}); + test('open API allowlist warning is hidden when API access is disabled', function () { $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); From 27c7dc6a243d5362419e88e60e919829682054de Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:29:14 +0200 Subject: [PATCH 27/48] chore(notifications): remove broken notification interface --- app/Notifications/Notification.php | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 app/Notifications/Notification.php diff --git a/app/Notifications/Notification.php b/app/Notifications/Notification.php deleted file mode 100644 index d37716a8b4..0000000000 --- a/app/Notifications/Notification.php +++ /dev/null @@ -1,22 +0,0 @@ - Date: Sat, 5 Sep 2026 12:32:25 +0200 Subject: [PATCH 28/48] fix(notifications): hetzner deletion failure channel name --- app/Notifications/Server/HetznerDeletionFailed.php | 3 +-- tests/Unit/HetznerDeletionFailedNotificationTest.php | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index bb452b054b..a109a4360d 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -17,8 +17,7 @@ class HetznerDeletionFailed extends CustomEmailNotification public function via(object $notifiable): array { - - return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + return $notifiable->getEnabledChannels('hetzner_deletion_failure'); } public function toMail(): MailMessage diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php index 22d5e80db3..bcd3f294ee 100644 --- a/tests/Unit/HetznerDeletionFailedNotificationTest.php +++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php @@ -19,7 +19,7 @@ it('can be instantiated with correct properties', function () { ->and($notification->errorMessage)->toBe('Hetzner API error: Server not found'); }); -it('uses hetzner_deletion_failed event for channels', function () { +it('uses the always-send hetzner_deletion_failure event for channels', function () { $notification = new HetznerDeletionFailed( hetznerServerId: 12345, teamId: 1, @@ -28,7 +28,7 @@ it('uses hetzner_deletion_failed event for channels', function () { $mockNotifiable = Mockery::mock(); $mockNotifiable->shouldReceive('getEnabledChannels') - ->with('hetzner_deletion_failed') + ->with('hetzner_deletion_failure') ->once() ->andReturn([]); From 9f6ed823ce5d1639ccc7ea5b254c0f6a7a95b877 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:33:06 +0200 Subject: [PATCH 29/48] fix(notifications): add toWebhook payload to Hetzner deletion failure notification --- app/Notifications/Server/HetznerDeletionFailed.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index a109a4360d..6c2712b68d 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -65,4 +65,16 @@ class HetznerDeletionFailed extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud. The server has been removed from Coolify, but may still exist in your Hetzner Cloud account.", + 'event' => 'hetzner_deletion_failed', + 'hetzner_server_id' => $this->hetznerServerId, + 'error' => $this->errorMessage, + 'url' => base_url().'/servers', + ]; + } } From fb393da352afd6c0ca41a9e6795f955b37a1db06 Mon Sep 17 00:00:00 2001 From: Bo Sundgaard <46848138+bosund@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:00:04 +0200 Subject: [PATCH 30/48] fix(docker): raise nginx request header buffers above the 8k default (#11404) Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> --- docker/development/etc/nginx/conf.d/custom.conf | 6 ++++++ docker/production/etc/nginx/conf.d/custom.conf | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/docker/development/etc/nginx/conf.d/custom.conf b/docker/development/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/development/etc/nginx/conf.d/custom.conf +++ b/docker/development/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; diff --git a/docker/production/etc/nginx/conf.d/custom.conf b/docker/production/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/production/etc/nginx/conf.d/custom.conf +++ b/docker/production/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; From 94bf7910ad5c2bbb12fdebee42bdf7f90c3f9147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8F=94=EF=B8=8F=20Peak?= <122374094+peaklabs-dev@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:00:58 +0200 Subject: [PATCH 31/48] fix(service): persist service database public access (#11633) --- app/Livewire/Project/Service/Index.php | 36 +++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 7980e07056..3b8ca7af19 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -307,36 +307,48 @@ class Index extends Component public function instantSave() { + $this->authorize('update', $this->serviceDatabase); try { - $this->authorize('update', $this->serviceDatabase); - if ($this->isPublic && ! $this->publicPort) { - $this->dispatch('error', 'Public port is required.'); - $this->isPublic = false; - - return; - } - $this->syncDatabaseData(true); - if ($this->serviceDatabase->is_public) { - if (! str($this->serviceDatabase->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); + if ($this->isPublic) { + if (! $this->publicPort) { + $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; - $this->serviceDatabase->is_public = false; return; } + if (! str($this->serviceDatabase->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->isPublic = false; + + return; + } + $this->persistPublicAccess(); StartDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); $this->dispatch('success', 'Database is now publicly accessible.'); } else { + $this->persistPublicAccess(); StopDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = null; $this->dispatch('success', 'Database is no longer publicly accessible.'); } } catch (\Throwable $e) { + $this->isPublic = ! $this->isPublic; + $this->persistPublicAccess(); + return handleError($e, $this); } } + private function persistPublicAccess(): void + { + $this->serviceDatabase->update([ + 'is_public' => $this->isPublic, + 'public_port' => $this->publicPort ?: null, + 'public_port_timeout' => $this->publicPortTimeout ?: null, + ]); + } + public function submitDatabase() { try { From 16295e1ab33bc8f7012d1daa2b58416ca55e1ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Devrim=20Tun=C3=A7er?= <151394142+devrim-1283@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:10:21 +0300 Subject: [PATCH 32/48] fix(ui): keep modal content across re-renders (#11294) --- resources/views/components/modal-input.blade.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index 588032e039..144a1a9f9a 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -15,10 +15,6 @@ 'isLarge' => false, ]) -@php - $modalId = 'modal-' . uniqid(); -@endphp -
-
Date: Sat, 5 Sep 2026 14:19:38 +0200 Subject: [PATCH 33/48] fix: avoid inherited compose ports and defer archive inspection Prevent multi-service Compose domains from inheriting the application port, and defer PostgreSQL custom-format archive inspection to pg_restore. --- app/Livewire/Project/Application/Domains.php | 7 ++ .../Project/Application/PreviewDomains.php | 7 ++ app/Support/DatabaseBackupFileValidator.php | 9 +-- bootstrap/helpers/parsers.php | 6 +- bootstrap/helpers/shared.php | 8 ++- tests/Feature/ApplicationDomainsTest.php | 65 +++++++++++++++++++ ...licationParserDockerComposeDomainsTest.php | 34 ++++++++++ .../DatabaseBackupUploadValidationTest.php | 8 +-- 8 files changed, 130 insertions(+), 14 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 839c61e36a..62153d1757 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -569,6 +569,13 @@ class Domains extends Component ]; } + if ($this->isCompose && $service !== null && count($this->composeServices) > 1) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + $exposed = $this->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index e6e9f5d60f..66eb2f5e01 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -537,6 +537,13 @@ class PreviewDomains extends Component ]; } + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null && count($this->composeServices()) > 1) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + $exposed = $this->preview->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 84e629fe1a..2c1de948ba 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,11 +90,8 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { - $requireStatementBoundary = true; - if (str_starts_with($sql, 'PGDMP')) { - $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; - $requireStatementBoundary = false; + return false; } $withoutComments = self::stripSqlComments($sql); @@ -103,9 +100,7 @@ class DatabaseBackupFileValidator return true; } - $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; - - return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 03ebf5af7a..dd8ea29c88 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -390,6 +390,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int return collect([]); } $services = data_get($yaml, 'services', collect([])); + $applicationServiceCount = collect($services) + ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) + ->count(); $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), 'networks' => collect(data_get($yaml, 'networks', [])), @@ -1355,7 +1358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ? ($previewForPorts?->domain_port_overrides ?? []) : ($originalResource->domain_port_overrides ?? []); $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); + $onlyPort = firstDockerComposeServicePort($service) + ?? ($applicationServiceCount === 1 ? ($exposedPorts[0] ?? null) : null); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index bc5af1e86e..f5db32e886 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3318,7 +3318,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id !== 0) { $definedNetwork = collect(["{$resource->uuid}-$pull_request_id"]); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id) { + $usesSharedApplicationPort = collect($services) + ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) + ->count() === 1; + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id, $usesSharedApplicationPort) { $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3916,7 +3919,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal ? ($resource->domain_port_overrides ?? []) : ($preview?->domain_port_overrides ?? []); $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); + $onlyPort = firstDockerComposeServicePort($service) + ?? ($usesSharedApplicationPort ? ($exposedPorts[0] ?? null) : null); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 396cccbfc9..1cc6d05e13 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2436,6 +2436,25 @@ it('shows the detected compose service port as the inherited internal port', fun ->assertDontSee('Internal port 3000'); }); +it('does not show an application port as the inherited port for a compose service without a declared port', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n backend:\n build: ./backend\n frontend:\n build: ./frontend\n", + 'docker_compose_domains' => json_encode([ + 'backend' => ['domain' => 'https://api.example.com'], + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + 'fqdn' => null, + 'domain_port_overrides' => null, + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSet('domainRows.0.internal_port', null) + ->assertSet('domainRows.1.internal_port', null) + ->assertDontSee('Internal port 3000'); +}); + it('shows the detected compose service port for preview domains', function () { $this->application->update([ 'build_pack' => 'dockercompose', @@ -2459,6 +2478,27 @@ it('shows the detected compose service port for preview domains', function () { ->assertDontSee('Internal port 3000'); }); +it('does not show an application port for a preview compose service without a declared port', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n backend:\n build: ./backend\n frontend:\n build: ./frontend\n", + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 8070, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/8070', + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://preview.example.com'], + ]), + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertSet('domainRows.0.internal_port', null) + ->assertDontSee('Internal port 3000'); +}); + it('keeps a legacy port-bearing url port in the edit field as an internal port override', function () { $this->application->update([ 'ports_exposes' => '3000,8080', @@ -2514,6 +2554,31 @@ it('stores compose domain port overrides without wiping other services', functio ->toHaveKey('https://api.example.com', 4000); }); +it('saves an unrecognized compose domain port after confirming the warning', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n frontend:\n build: ./frontend\n", + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + 'domain_port_overrides' => null, + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->set('editingDomainParts.port', '80') + ->call('updateDomain') + ->assertSet('showPortWarningModal', true) + ->call('confirmUseUnknownPort') + ->assertSet('showPortWarningModal', false) + ->assertDispatched('success'); + + expect($this->application->fresh()->domain_port_overrides) + ->toBe(['https://app.example.com' => 80]); +}); + it('prunes a compose domain port override when that domain is removed', function () { $this->application->update([ 'build_pack' => 'dockercompose', diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 30305ef596..c207782022 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -631,3 +631,37 @@ YAML, 'short port syntax' => " ports:\n - '18069:8069'", 'long port syntax' => " ports:\n - target: 8069\n published: 18069", ]); + +test('applicationParser does not apply an application port to compose services without a declared port', function () { + $application = disableExactProxyLabels(Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => <<<'YAML' +services: + postgres: + image: postgres:16-alpine + backend: + build: ./backend + frontend: + build: ./frontend +YAML, + 'fqdn' => null, + 'domain_port_overrides' => null, + 'docker_compose_domains' => json_encode([ + 'backend' => ['domain' => 'https://api.example.com'], + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + ])); + + $services = data_get(applicationParser($application->fresh()), 'services'); + $backendLabels = collect(data_get($services, 'backend.labels')); + $frontendLabels = collect(data_get($services, 'frontend.labels')); + + expect($backendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) + ->toBeFalse() + ->and($frontendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) + ->toBeFalse(); +}); diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php index 88978eeaba..f8416fe6c7 100644 --- a/tests/Feature/DatabaseBackupUploadValidationTest.php +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -220,16 +220,16 @@ test('file scanner allows ordinary gzipped dumps', function () { expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse(); }); -test('file scanner detects program execution payloads inside custom format archives', function () { +test('file scanner defers custom format archives to pg_restore inspection', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload"); - expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue(); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); -test('file scanner detects program execution payloads inside gzipped custom format archives', function () { +test('file scanner defers gzipped custom format archives to pg_restore inspection', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload", gzip: true); - expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue(); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); test('file scanner allows custom format archives without program execution', function () { From 47c61feb8470e353f953baceca774735bae86b6d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:24:24 +0200 Subject: [PATCH 34/48] fix(api): persist Docker Compose domain ports as overrides Normalize Compose domains on create and update, retaining explicit ports in `domain_port_overrides` while storing port-free domain values. Preserve empty Compose FQDNs and cover both API flows with feature tests. --- .../Api/ApplicationsController.php | 36 ++++++++++++ app/Models/Application.php | 9 ++- .../Api/ApplicationSettingsApiTest.php | 55 +++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 487d319e18..dbe0f86336 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -27,6 +27,7 @@ use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -1455,7 +1456,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $repository_url_parsed = Url::fromString($request->git_repository); $git_host = $repository_url_parsed->getHost(); @@ -1719,7 +1722,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1950,7 +1955,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; @@ -3369,7 +3376,12 @@ class ApplicationsController extends Controller } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts( + $dockerComposeDomainsJson, + $application->domain_port_overrides, + ); data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); + data_set($data, 'domain_port_overrides', $domainPortOverrides); } $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); @@ -6116,4 +6128,28 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Destination detached.']); } + + /** + * @param Collection $domains + * @param array|null $existingOverrides + * @return array{Collection, ?array} + */ + private function normalizeDockerComposeDomainPorts(Collection $domains, ?array $existingOverrides = null): array + { + $allDomains = $domains + ->pluck('domain') + ->filter() + ->implode(','); + $normalized = DomainPortOverrides::normalize($allDomains, $existingOverrides); + + $domains = $domains->map(function (array $entry): array { + $entry['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'] ?? null)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + + return $entry; + }); + + return [$domains, $normalized['overrides']]; + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 7f7ce5ea1f..2ac352149f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; +use App\Enums\BuildPackTypes; use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; @@ -291,9 +292,11 @@ class Application extends BaseModel if ($application->fqdn === '') { $application->fqdn = null; } - $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); - $application->fqdn = $normalized['fqdn']; - $application->domain_port_overrides = $normalized['overrides']; + if ($application->build_pack !== BuildPackTypes::DOCKERCOMPOSE->value || filled($application->fqdn)) { + $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); + $application->fqdn = $normalized['fqdn']; + $application->domain_port_overrides = $normalized['overrides']; + } $payload['fqdn'] = $application->fqdn; $application->syncNoindexDomains(); } diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index 55d25927a3..a4c22a2b7c 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -165,6 +165,61 @@ test('changing a domain port regenerates managed labels with the requested port' ->and($labels)->not->toContain('loadbalancer.server.port=3000'); }); +test('compose domain ports are stored as overrides when updating through the API', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n api:\n image: nginx\n frontend:\n image: nginx\n", + 'docker_compose_domains' => json_encode([ + 'api' => ['domain' => 'https://api.example.com'], + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'docker_compose_domains' => [ + ['name' => 'api', 'domain' => 'https://api.example.com'], + ['name' => 'frontend', 'domain' => 'https://app.example.com:80'], + ], + ]) + ->assertOk(); + + $application = $this->application->fresh(); + $domains = json_decode($application->docker_compose_domains, true); + + expect(data_get($domains, 'frontend.domain'))->toBe('https://app.example.com') + ->and($application->domain_port_overrides)->toBe([ + 'https://app.example.com' => 80, + ]); +}); + +test('compose domain ports are stored as overrides when creating through the API', function () { + Queue::fake(); + + $response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/compose-domain-port-test', + 'git_branch' => 'main', + 'build_pack' => 'dockercompose', + 'autogenerate_domain' => false, + 'docker_compose_domains' => [ + ['name' => 'frontend', 'domain' => 'https://app.example.com:80'], + ], + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + $domains = json_decode($application->docker_compose_domains, true); + + expect(data_get($domains, 'frontend.domain'))->toBe('https://app.example.com') + ->and($application->domain_port_overrides)->toBe([ + 'https://app.example.com' => 80, + ]); +}); + test('http basic auth updates regenerate managed labels', function () { $this->application->settings->update(['is_container_label_readonly_enabled' => true]); $this->application->update([ From 2a25e0490e3672e25a6a6cbe166849debd80edef Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:18:26 +0200 Subject: [PATCH 35/48] fix(storage): persist S3 settings for new volume backups (#11635) --- .../Project/Shared/Storages/VolumeBackups.php | 16 ++- tests/Feature/VolumeBackupTest.php | 112 +++++++++++++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a10eb5ad03..a8e2d72df1 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -147,7 +147,11 @@ class VolumeBackups extends Component } $this->resetErrorBag('s3StorageId'); - $this->backup?->update(['s3_storage_id' => $this->s3StorageId]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', 'S3 storage updated.'); } @@ -163,11 +167,11 @@ class VolumeBackups extends Component $this->saveToS3 = ! $this->saveToS3; $this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup; - $this->backup?->update([ - 'save_s3' => $this->saveToS3, - 'disable_local_backup' => $this->disableLocalBackup, - 's3_storage_id' => $this->s3StorageId, - ]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.'); } diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index b24b45dcad..9c6897632a 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -960,6 +960,67 @@ it('enables and disables volume S3 backups from the S3 title action', function ( expect($backup->refresh()->save_s3)->toBeFalse(); }); +it('persists S3 settings the first time when a volume backup schedule does not exist yet', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + + Livewire::test(VolumeBackups::class, [ + 'storage' => $volume, + 'resource' => $application, + 'section' => 's3', + ]) + ->assertSet('s3StorageId', $s3Storage->id) + ->call('toggleS3') + ->assertSet('saveToS3', true) + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + + expect($backup->enabled)->toBeFalse() + ->and($backup->save_s3)->toBeTrue() + ->and($backup->s3_storage_id)->toBe($s3Storage->id); +}); + +it('persists the selected S3 storage when a volume backup schedule does not exist yet', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $s3Storage = S3Storage::create([ + 'name' => 'Volume backups', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + + Livewire::test(VolumeBackups::class, [ + 'storage' => $volume, + 'resource' => $application, + 'section' => 's3', + ]) + ->set('s3StorageId', $s3Storage->id) + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + + expect($backup->enabled)->toBeFalse() + ->and($backup->s3_storage_id)->toBe($s3Storage->id); +}); + it('shows and saves volume S3 retention while S3 backups are disabled', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); @@ -1004,7 +1065,7 @@ it('allows team owners to edit volume backup retention settings', function () { ])->assertDontSee('You do not have permission to perform this action.'); }); -it('only updates S3 fields when toggling volume S3 backups', function () { +it('does not enable S3 backups when another volume backup setting is invalid', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); [$application, $volume] = createVolumeBackupApplication($team); @@ -1031,9 +1092,54 @@ it('only updates S3 fields when toggling volume S3 backups', function () { ]) ->set('frequency', 'not a valid schedule') ->call('toggleS3') - ->assertDispatched('success'); + ->assertHasErrors('frequency') + ->assertNotDispatched('success'); - expect($backup->refresh()->save_s3)->toBeTrue() + expect($backup->refresh()->save_s3)->toBeFalse() + ->and($backup->frequency)->toBe('daily'); +}); + +it('does not change S3 storage when another volume backup setting is invalid', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application, $volume] = createVolumeBackupApplication($team); + $firstS3Storage = S3Storage::create([ + 'name' => 'First storage', + 'region' => 'us-east-1', + 'key' => 'first-key', + 'secret' => 'secret', + 'bucket' => 'first-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $secondS3Storage = S3Storage::create([ + 'name' => 'Second storage', + 'region' => 'us-east-1', + 'key' => 'second-key', + 'secret' => 'secret', + 'bucket' => 'second-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 's3_storage_id' => $firstS3Storage->id, + ]); + + Livewire::test(VolumeBackups::class, [ + 'storage' => $volume, + 'resource' => $application, + 'section' => 's3', + ]) + ->set('frequency', 'not a valid schedule') + ->set('s3StorageId', $secondS3Storage->id) + ->assertHasErrors('frequency') + ->assertNotDispatched('success'); + + expect($backup->refresh()->s3_storage_id)->toBe($firstS3Storage->id) ->and($backup->frequency)->toBe('daily'); }); From bf874029d81a6350ca828cd585a5af6ae161a0d2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:18:37 +0200 Subject: [PATCH 36/48] fix(terminal): preserve PATH for SSH proxy commands (#11638) --- config/constants.php | 2 +- docker-compose.prod.yml | 2 +- docker-compose.windows.yml | 2 +- docker/coolify-realtime/terminal-server.js | 3 ++- docker/coolify-realtime/terminal-utils.js | 8 ++++++ .../coolify-realtime/terminal-utils.test.js | 27 +++++++++++++++++++ 6 files changed, 40 insertions(+), 4 deletions(-) diff --git a/config/constants.php b/config/constants.php index bdcd5d7c9d..1e6d40b7b6 100644 --- a/config/constants.php +++ b/config/constants.php @@ -4,7 +4,7 @@ return [ 'coolify' => [ 'version' => env('COOLIFY_VERSION') ?: '4.3.18', 'helper_version' => '1.0.16', - 'realtime_version' => '1.0.18', + 'realtime_version' => '1.0.19', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ebf12379d5..d611fc69f7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.19' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cc266e5562..6a52a0126a 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -97,7 +97,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.19' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index b72574c8d0..09f5bd5e12 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -8,6 +8,7 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, sanitizeSshArgs, @@ -401,7 +402,7 @@ async function handleCommand(ws, command, userId) { cols: 80, rows: 30, cwd: process.env.HOME, - env: {}, + env: getTerminalProcessEnv(), }; // NOTE: - Initiates a process within the Terminal container diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 4e86dc1f73..0d13dc18f1 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -1,5 +1,13 @@ export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; +const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + +export function getTerminalProcessEnv(environment = process.env) { + return { + PATH: environment.PATH || DEFAULT_TERMINAL_PATH, + }; +} + export function getTerminalSessionTimeout() { return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS; } diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 5ca0be8304..21625eece4 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -4,6 +4,7 @@ import { MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, extractSshArgs, extractTargetHost, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, @@ -11,6 +12,32 @@ import { validateSshArgs, } from './terminal-utils.js'; +test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '/usr/local/bin:/usr/bin:/bin', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/bin:/usr/bin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is absent', () => { + assert.deepEqual(getTerminalProcessEnv({ + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is empty', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { const sshArgs = extractSshArgs( "timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" From b351e94c98cf21c1cfba193c0cc457ac6665dcc5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:18:51 +0200 Subject: [PATCH 37/48] fix(api): return task execution duration as float (#11636) --- app/Models/ScheduledTaskExecution.php | 2 +- tests/Feature/ModelFillableCreationTest.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 1e26c7be3f..8f496fc1e6 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel 'started_at' => 'datetime', 'finished_at' => 'datetime', 'retry_count' => 'integer', - 'duration' => 'decimal:2', + 'duration' => 'float', ]; } diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index 46d9c36daa..1e06ea22d3 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -1047,14 +1047,15 @@ it('creates ScheduledTaskExecution with all fillable attributes', function () { 'finished_at' => now()->toISOString(), 'started_at' => now()->subMinute()->toISOString(), 'retry_count' => 0, - 'duration' => 60, + 'duration' => '60.25', 'error_details' => null, ]); expect($execution->exists)->toBeTrue(); expect($execution->scheduled_task_id)->toBe($task->id); expect($execution->status)->toBe('success'); - expect((float) $execution->duration)->toBe(60.0); + expect($execution->duration)->toBe(60.25); + expect($execution->toArray()['duration'])->toBe(60.25); expect($execution->retry_count)->toBe(0); }); From 3764771293e4b747930acd34a8d3ddd9d1748e04 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:18:59 +0200 Subject: [PATCH 38/48] fix(webhooks): handle closed PRs after base branch changes (#11634) --- app/Http/Controllers/Webhook/Github.php | 10 +- .../GithubPullRequestWebhookRoutingTest.php | 156 ++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/GithubPullRequestWebhookRoutingTest.php diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 28e92dcd49..c4fdc5fd5c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -83,7 +83,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); } @@ -334,7 +337,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $applications->get(); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with branch '$base_branch'."); } diff --git a/tests/Feature/GithubPullRequestWebhookRoutingTest.php b/tests/Feature/GithubPullRequestWebhookRoutingTest.php new file mode 100644 index 0000000000..7768dbd903 --- /dev/null +++ b/tests/Feature/GithubPullRequestWebhookRoutingTest.php @@ -0,0 +1,156 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create(array_merge([ + 'name' => 'github-pr-webhook-app', + 'git_repository' => 'https://github.com/test-org/test-repo', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $overrides)); +} + +function githubPullRequestPayload(string $action, string $baseBranch): array +{ + return [ + 'action' => $action, + 'number' => 42, + 'repository' => [ + 'id' => 987654321, + 'full_name' => 'test-org/test-repo', + ], + 'pull_request' => [ + 'html_url' => 'https://github.com/test-org/test-repo/pull/42', + 'title' => 'Stacked change', + 'author_association' => 'OWNER', + 'head' => [ + 'ref' => 'feature/child', + 'sha' => 'head-sha', + ], + 'base' => [ + 'ref' => $baseBranch, + ], + ], + ]; +} + +it('routes a closed GitHub App pull request to its application after the base branch changes', function () { + Queue::fake(); + + $team = Team::factory()->create(); + $githubApp = GithubApp::create([ + 'name' => 'github-app-webhook-test', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567890, + 'webhook_secret' => 'test-secret', + 'is_public' => false, + 'team_id' => $team->id, + ]); + $application = createGithubPullRequestWebhookApplication([ + 'repository_project_id' => 987654321, + 'source_id' => $githubApp->id, + 'source_type' => GithubApp::class, + ]); + + $body = json_encode(githubPullRequestPayload('closed', 'feature/parent'), JSON_THROW_ON_ERROR); + $response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'test-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $body); + + $response->assertOk(); + expect($response->getContent())->toContain('PR webhook received'); + Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->applicationId === $application->id + && $job->action === 'closed' + && $job->pullRequestId === 42); +}); + +it('routes a closed manual pull request to its application after the base branch changes', function () { + Queue::fake(); + + $application = createGithubPullRequestWebhookApplication(); + $payload = githubPullRequestPayload('closed', 'feature/parent'); + $body = json_encode($payload, JSON_THROW_ON_ERROR); + + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_github), + 'CONTENT_TYPE' => 'application/json', + ], $body); + + $response->assertOk(); + Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->applicationId === $application->id + && $job->action === 'closed' + && $job->pullRequestId === 42); +}); + +it('continues to filter non-closed pull requests by base branch', function (string $endpoint) { + Queue::fake(); + + if ($endpoint === 'app') { + $team = Team::factory()->create(); + $githubApp = GithubApp::create([ + 'name' => 'github-app-webhook-test', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567890, + 'webhook_secret' => 'test-secret', + 'is_public' => false, + 'team_id' => $team->id, + ]); + createGithubPullRequestWebhookApplication([ + 'repository_project_id' => 987654321, + 'source_id' => $githubApp->id, + 'source_type' => GithubApp::class, + ]); + + $body = json_encode(githubPullRequestPayload('opened', 'feature/parent'), JSON_THROW_ON_ERROR); + $response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'test-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $body); + } else { + $application = createGithubPullRequestWebhookApplication(); + $body = json_encode(githubPullRequestPayload('opened', 'feature/parent'), JSON_THROW_ON_ERROR); + $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $application->manual_webhook_secret_github), + 'CONTENT_TYPE' => 'application/json', + ], $body); + } + + $response->assertOk(); + Queue::assertNotPushed(ProcessGithubPullRequestWebhook::class); +})->with(['app', 'manual']); From 08f68016dd342fe944564f79969f4ceceddc88d6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:58:12 +0200 Subject: [PATCH 39/48] fix(storage): prevent PR suffix dropdown clipping (#11637) --- resources/css/app.css | 5 +- .../views/components/forms/listbox.blade.php | 57 ++++++----- .../project/shared/storages/all.blade.php | 98 +++++++++++-------- .../Feature/ListboxTriggerTruncationTest.php | 7 ++ .../PersistentStorageVolumesLayoutTest.php | 68 +++++++++++++ .../Browser/ApplicationConfigurationTest.php | 56 +++++++++++ 6 files changed, 223 insertions(+), 68 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index 40af521c69..06f0b251af 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2673,7 +2673,10 @@ input[type="search"]::-webkit-search-results-decoration { } .volumes-col-backup { - align-items: flex-start; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; } .volumes-cell-actions { diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index d04a5f57e5..4c572596d6 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -123,7 +123,8 @@ {{ $attributes->whereStartsWith('x-model') }} {{ $attributes->whereStartsWith('x-effect') }} @if ($preserveValue) wire:ignore @endif - @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()"> + @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()" + @scroll.window.capture="open && positionPanel()"> @if ($portal) -
diff --git a/resources/views/livewire/project/database/backup-edit/s3.blade.php b/resources/views/livewire/project/database/backup-edit/s3.blade.php index 180ff0a169..a06792bb09 100644 --- a/resources/views/livewire/project/database/backup-edit/s3.blade.php +++ b/resources/views/livewire/project/database/backup-edit/s3.blade.php @@ -35,12 +35,12 @@ @endif
- - Back up now diff --git a/resources/views/livewire/project/service/backup-executions.blade.php b/resources/views/livewire/project/service/backup-executions.blade.php index b5c2057cfd..ee992b9a47 100644 --- a/resources/views/livewire/project/service/backup-executions.blade.php +++ b/resources/views/livewire/project/service/backup-executions.blade.php @@ -21,11 +21,17 @@ + @if ($executions->total() > 10) + + + + @endif @if ($executions->isEmpty()) @else -
+
+
TargetTypeScheduleStatusStartedSizeActions
@@ -42,7 +48,13 @@ wire:click="openExecution('{{ $execution['uuid'] }}')" wire:keydown.enter="openExecution('{{ $execution['uuid'] }}')" role="button" tabindex="0" class="data-table-row grid min-w-[820px] cursor-pointer grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_110px_90px_48px] text-left text-[13px] text-neutral-700 dark:text-fg-dim"> - {{ $execution['target'] }} + + {{ $execution['target'] }} + + + {{ $execution['type'] }}{{ $execution['schedule'] }} {{ $execution['started_at']->diffForHumans() }} @@ -58,6 +70,12 @@
@endforeach + @if ($executions->hasPages()) + + @endif
@endif
diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index f69d3f0db2..9ac25ec39d 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -240,7 +240,7 @@ @if ($backups->isNotEmpty() || $databaseBackups->isNotEmpty())
-
+
Target Type @@ -268,6 +268,8 @@ default => 'neutral', }; $databaseBackupId = 'database:'.$databaseBackup->id; + $databaseS3 = $databaseBackup->s3?->team_id === currentTeam()->id ? $databaseBackup->s3 : null; + $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable'); @endphp
{{ $databaseBackup->frequency }} - + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} - + Back up now + Settings
@endforeach @@ -297,6 +304,8 @@ @foreach ($backups as $backup) @php $latestExecution = $backup->latestExecution; + $volumeS3 = $backup->s3?->team_id === currentTeam()->id ? $backup->s3 : null; + $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable'); $status = $latestExecution?->status; $statusLabel = match ($status) { 'running' => 'In progress', @@ -324,17 +333,20 @@ {{ $backup->targetType() }} {{ $backup->frequency }} - - + + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} - + Back up now + Settings
@endforeach diff --git a/resources/views/livewire/server/resources.blade.php b/resources/views/livewire/server/resources.blade.php index 9fb2ca2fcc..78ed0eb01a 100644 --- a/resources/views/livewire/server/resources.blade.php +++ b/resources/views/livewire/server/resources.blade.php @@ -30,9 +30,27 @@ +
+
+ + + +
+
+ +
diff --git a/resources/views/livewire/server/sentinel/logs.blade.php b/resources/views/livewire/server/sentinel/logs.blade.php index 455bb55bb2..37f8367fd1 100644 --- a/resources/views/livewire/server/sentinel/logs.blade.php +++ b/resources/views/livewire/server/sentinel/logs.blade.php @@ -10,15 +10,27 @@ - - - -
- -
+ @if ($server->isSentinelEnabled()) + + + +
+ +
+ @else + + + Enable Sentinel + + + + @endif
diff --git a/resources/views/livewire/shared-variables/server/show.blade.php b/resources/views/livewire/shared-variables/server/show.blade.php index 4bba5eb064..445b6899e9 100644 --- a/resources/views/livewire/shared-variables/server/show.blade.php +++ b/resources/views/livewire/shared-variables/server/show.blade.php @@ -4,7 +4,8 @@
diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 42a430a491..03af1bea4f 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -247,6 +247,7 @@ it('redirects to executions after queuing a database backup with unusable S3 sto 'timeout' => 3600, ]); $database = $backup->database; + $database->update(['status' => 'running:healthy']); $parameters = [ 'project_uuid' => $database->project()->uuid, 'environment_uuid' => $database->environment->uuid, @@ -509,7 +510,7 @@ it('subscribes to database status broadcasts so Backup Now can refresh without a ->toHaveKey('databaseUpdated'); }); -it('shows Backup Now after refresh when the database becomes running', function () { +it('enables Back up now after refresh when the database becomes running', function () { $backup = createBackupForEditValidationTest($this->team, [ 'enabled' => true, ]); @@ -521,17 +522,21 @@ it('shows Backup Now after refresh when the database becomes running', function 'availableS3Storages' => $this->team->s3s, 'status' => 'exited:unhealthy', ]) - ->assertDontSee('Backup Now') + ->assertSee('Back up now') ->assertSet('status', 'exited:unhealthy'); + expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); + $database->update(['status' => 'running:healthy']); $component->call('refreshStatus') ->assertSet('status', 'running:healthy') - ->assertSee('Backup Now'); + ->assertSee('Back up now'); + + expect($component->html())->not->toMatch('/]*wire:click="backupNow"/s'); }); -it('hides Backup Now after refresh when the database stops', function () { +it('disables Back up now after refresh when the database stops', function () { $backup = createBackupForEditValidationTest($this->team, [ 'enabled' => true, ]); @@ -543,12 +548,33 @@ it('hides Backup Now after refresh when the database stops', function () { 'availableS3Storages' => $this->team->s3s, 'status' => 'running:healthy', ]) - ->assertSee('Backup Now') + ->assertSee('Back up now') ->assertSet('status', 'running:healthy'); $database->update(['status' => 'exited:unhealthy']); $component->call('refreshStatus') ->assertSet('status', 'exited:unhealthy') - ->assertDontSee('Backup Now'); + ->assertSee('Back up now'); + + expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); +}); + +it('renders S3 backup selectors outside the scrollable modal', function () { + createS3StorageForBackupEditValidationTest($this->team); + $backup = createBackupForEditValidationTest($this->team); + $html = Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + 'section' => 's3', + ])->html(); + + $dom = new DOMDocument; + @$dom->loadHTML($html); + $xpath = new DOMXPath($dom); + foreach (['s3StorageId-panel', 'disableLocalBackup-panel'] as $panelId) { + $panels = $xpath->query('//template[@x-teleport="body"]/div[@id="'.$panelId.'"]'); + expect($panels->length)->toBe(1); + expect($panels->item(0)->getAttribute('style'))->toContain('position: fixed', 'z-index: 9999'); + } }); diff --git a/tests/Feature/BackupNowAvailabilityTest.php b/tests/Feature/BackupNowAvailabilityTest.php new file mode 100644 index 0000000000..7c103e001f --- /dev/null +++ b/tests/Feature/BackupNowAvailabilityTest.php @@ -0,0 +1,132 @@ + 0]); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $resourceAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + $this->service = Service::factory()->create(['server_id' => $server->id, ...$resourceAttributes]); + $this->databases = [ + 'standalone' => StandalonePostgresql::create([ + ...$resourceAttributes, + 'name' => 'postgres', + 'postgres_password' => 'password', + ]), + 'service' => ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]), + ]; + $this->backups = collect($this->databases)->map(fn ($database) => ScheduledDatabaseBackup::create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + ])); + Queue::fake(); +}); + +dataset('database backup controls', [ + 'standalone settings' => [BackupEdit::class, 'standalone'], + 'service settings' => [BackupEdit::class, 'service'], + 'standalone button' => [BackupNow::class, 'standalone'], + 'service button' => [BackupNow::class, 'service'], + 'service list' => [Index::class, 'service'], +]); + +it('only enables and queues database backups for running targets', function (string $componentClass, string $type, string $status, bool $running) { + $database = $this->databases[$type]; + $database->update(['status' => $status]); + $backup = $this->backups[$type]->fresh(); + $parameters = $componentClass === Index::class + ? ['service' => $this->service] + : ['backup' => $backup, ...($componentClass === BackupEdit::class ? ['availableS3Storages' => collect()] : [])]; + $component = Livewire::test($componentClass, $parameters); + $dom = new DOMDocument; + @$dom->loadHTML($component->html()); + $buttons = (new DOMXPath($dom))->query('//button'); + $backupButtons = []; + foreach ($buttons as $button) { + if (str_starts_with($button->getAttribute('wire:click'), 'backupNow') || str_starts_with($button->getAttribute('wire:click.stop'), 'backupNow')) { + $backupButtons[] = $button; + } + } + expect($backupButtons)->toHaveCount(1); + expect($backupButtons[0]->hasAttribute('disabled'))->toBe(! $running); + + $component->call('backupNow', ...($componentClass === Index::class ? ['database', $backup->uuid] : [])); + if ($running) { + Queue::assertPushed(DatabaseBackupJob::class); + } else { + $component->assertDispatched('error')->assertNotDispatched('success')->assertNoRedirect(); + Queue::assertNotPushed(DatabaseBackupJob::class); + } +})->with('database backup controls')->with([ + ['running:healthy', true], + ['running:unhealthy', true], + ['exited:unhealthy', false], + ['restarting:unhealthy', false], +]); + +it('checks current database status before queuing from a stale page', function (string $componentClass, string $type) { + $database = $this->databases[$type]; + $database->update(['status' => 'running:healthy']); + $backup = $this->backups[$type]->fresh(); + $parameters = $componentClass === Index::class + ? ['service' => $this->service] + : ['backup' => $backup, ...($componentClass === BackupEdit::class ? ['availableS3Storages' => collect()] : [])]; + $component = Livewire::test($componentClass, $parameters); + $database->update(['status' => 'exited:unhealthy']); + + $component->call('backupNow', ...($componentClass === Index::class ? ['database', $backup->uuid] : [])) + ->assertDispatched('error')->assertNotDispatched('success')->assertNoRedirect(); + Queue::assertNotPushed(DatabaseBackupJob::class); +})->with('database backup controls'); + +it('refreshes service backup buttons when the service status check completes', function () { + $database = $this->databases['service']; + $database->update(['status' => 'exited:unhealthy']); + $component = Livewire::test(Index::class, ['service' => $this->service]); + $database->update(['status' => 'running:healthy']); + + $component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked'); + expect($component->html())->not->toMatch('/]*wire:click.stop="backupNow/s'); + + $database->update(['status' => 'exited:unhealthy']); + $component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked'); + expect($component->html())->toMatch('/]*wire:click.stop="backupNow/s'); +}); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php index 1b18c31b41..a96e776a05 100644 --- a/tests/Feature/CreateScheduledBackupValidationTest.php +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -82,11 +82,10 @@ it('creates a service database backup without S3 and opens its configuration', f $backup = ScheduledDatabaseBackup::query()->sole(); - $component->assertRedirectToRoute('project.service.database.backup.show', [ + $component->assertRedirectToRoute('project.service.volume-backups.index', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $backup->uuid, ]); @@ -114,14 +113,22 @@ it('selects a service database when creating a backup from the unified backups p 'custom_type' => 'postgresql', ]); - Livewire::test(CreateScheduledBackup::class, ['service' => $service]) + $component = Livewire::test(CreateScheduledBackup::class, ['service' => $service]) ->assertSee('Database') ->assertSee('analytics') ->set('selectedDatabaseUuid', $analytics->uuid) ->set('frequency', 'daily') ->call('submit'); - expect(ScheduledDatabaseBackup::query()->sole()->database->is($analytics))->toBeTrue(); + $backup = ScheduledDatabaseBackup::query()->sole(); + expect($backup->database->is($analytics))->toBeTrue(); + + $component->assertRedirectToRoute('project.service.volume-backups.index', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'service_uuid' => $service->uuid, + 'backup_uuid' => $backup->uuid, + ]); }); it('creates a clickhouse backup for its configured database', function () { diff --git a/tests/Feature/Livewire/SentinelLogsTest.php b/tests/Feature/Livewire/SentinelLogsTest.php new file mode 100644 index 0000000000..99d2fa1051 --- /dev/null +++ b/tests/Feature/Livewire/SentinelLogsTest.php @@ -0,0 +1,127 @@ + 0]); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + $this->actingAs($user); + $this->server = Server::factory()->create(['team_id' => $team->id]); +}); + +it('does not show sync status or fetch logs when sentinel is disabled', function (bool $recentHeartbeat) { + $this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat); + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertSee('Sentinel is disabled') + ->assertSeeHtml('wire:click="enableSentinel"') + ->assertDontSee('Out of sync') + ->assertDontSee('In sync') + ->assertDontSeeLivewire(GetLogs::class); +})->with([false, true]); + +it('shows sync status and logs when sentinel is enabled', function (bool $metricsOnly, bool $recentHeartbeat) { + $this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat); + $this->server->settings()->update([ + 'is_sentinel_enabled' => ! $metricsOnly, + 'is_metrics_enabled' => $metricsOnly, + 'is_build_server' => false, + ]); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertDontSee('Sentinel is disabled') + ->assertSee($recentHeartbeat ? 'In sync' : 'Out of sync') + ->assertSeeLivewire(GetLogs::class); +})->with([false, true])->with([false, true]); + +it('enables sentinel from the logs page', function () { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + StartSentinel::shouldRun()->once()->withArgs(function (Server $server, bool $restart): bool { + expect($server->id)->toBe($this->server->id); + expect($restart)->toBeTrue(); + $server->settings->update(['is_sentinel_enabled' => true]); + + return true; + }); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertDontSee('Sentinel is disabled') + ->assertDontSee('Enable Sentinel') + ->assertSeeLivewire(GetLogs::class) + ->assertDispatched('refreshServerShow') + ->assertDispatched('success'); + + expect($this->server->fresh()->isSentinelEnabled())->toBeTrue(); +}); + +it('keeps sentinel disabled when startup fails', function () { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + StartSentinel::shouldRun()->once()->andThrow(new RuntimeException('Startup failed')); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertSee('Sentinel is disabled') + ->assertDontSeeLivewire(GetLogs::class) + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($this->server->fresh()->isSentinelEnabled())->toBeFalse(); +}); + +it('does not enable sentinel on unsupported servers', function (string $setting) { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false, $setting => true]); + StartSentinel::shouldRun()->never(); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->call('enableSentinel') + ->assertSee('Sentinel is disabled') + ->assertDispatched('error'); +})->with(['is_build_server', 'is_swarm_manager', 'is_swarm_worker']); + +it('denies enabling sentinel to members and users outside the server team', function (bool $crossTeam) { + $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); + $user = User::factory()->create(); + if (! $crossTeam) { + $this->server->team->members()->attach($user->id, ['role' => 'member']); + } + $this->actingAs($user); + StartSentinel::shouldRun()->never(); + $component = new Logs; + $component->server = $this->server->fresh(); + + expect(fn () => $component->enableSentinel()) + ->toThrow(AuthorizationException::class); + expect($this->server->fresh()->isSentinelEnabled())->toBeFalse(); +})->with([false, true]); + +it('does not restart sentinel when it is already enabled', function () { + $this->server->settings()->update(['is_sentinel_enabled' => true, 'is_build_server' => false]); + StartSentinel::shouldRun()->never(); + + Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) + ->test(Logs::class) + ->assertSeeLivewire(GetLogs::class) + ->call('enableSentinel') + ->assertNotDispatched('success'); +}); diff --git a/tests/Feature/ServerResourcesPaginationTest.php b/tests/Feature/ServerResourcesPaginationTest.php new file mode 100644 index 0000000000..0f9efaae40 --- /dev/null +++ b/tests/Feature/ServerResourcesPaginationTest.php @@ -0,0 +1,141 @@ +component = new Resources; + $this->component->server = Mockery::mock(Server::class)->makePartial(); +}); + +it('paginates sorted resources on both tabs', function (string $tab, string $nameKey) { + $rows = collect(range(23, 1))->map(fn (int $id) => [$nameKey => "Resource $id"]); + $this->component->activeTab = $tab; + if ($tab === 'managed') { + $this->component->server->shouldReceive('definedResources')->andReturn($rows); + } else { + $this->component->unmanagedContainers = $rows->all(); + } + + $page = $this->component->render()->getData()['resources']; + expect($page->total())->toBe(23) + ->and($page->count())->toBe(10) + ->and($page->pluck($nameKey)->all())->toBe(array_map(fn ($id) => "Resource $id", range(1, 10))); + + $this->component->nextPage(); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe(2)->and($page->count())->toBe(10) + ->and($page->first()[$nameKey])->toBe('Resource 11'); + + $this->component->nextPage(); + $page = $this->component->render()->getData()['resources']; + expect($page->count())->toBe(3)->and($page->first()[$nameKey])->toBe('Resource 21'); + + $this->component->previousPage(); + expect($this->component->render()->getData()['resources']->currentPage())->toBe(2); +})->with([['managed', 'name'], ['unmanaged', 'Names']]); + +it('resets pagination and search when switching tabs but preserves them on refresh', function () { + $this->component->server->shouldReceive('refresh')->andReturnSelf(); + $this->component->server->shouldReceive('loadUnmanagedContainers')->andReturn(collect()); + $this->component->setPage(3); + $this->component->search = 'managed resource'; + $this->component->loadUnmanagedContainers(); + expect($this->component->getPage())->toBe(1) + ->and($this->component->search)->toBe(''); + $this->component->setPage(2); + $this->component->search = 'container'; + $this->component->loadUnmanagedContainers(); + expect($this->component->getPage())->toBe(2) + ->and($this->component->search)->toBe('container'); + $this->component->loadManagedContainers(); + expect($this->component->getPage())->toBe(1) + ->and($this->component->search)->toBe(''); + $this->component->setPage(2); + $this->component->search = 'application'; + $this->component->loadManagedContainers(); + expect($this->component->getPage())->toBe(2) + ->and($this->component->search)->toBe('application'); +}); + +it('clamps page size and resets the page', function (int $size, int $expected) { + $this->component->setPage(3); + $this->component->perPage = $size; + $this->component->updatedPerPage(); + expect($this->component->perPage)->toBe($expected) + ->and($this->component->getPage())->toBe(1); +})->with([[25, 25], [0, 1], [200, 100]]); + +it('clamps stale pages after resources disappear including an empty list', function (int $count, int $expectedPage) { + $this->component->activeTab = 'unmanaged'; + $this->component->unmanagedContainers = array_fill(0, $count, ['Names' => 'Container']); + $this->component->setPage(9); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe($expectedPage) + ->and($this->component->getPage())->toBe($expectedPage) + ->and($page->total())->toBe($count); +})->with([[12, 2], [0, 1]]); + +it('renders shared pagination for both resource tabs with stable row identities', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + expect($view)->toContain('toContain('toContain('previous-action="previousPage"') + ->toContain('next-action="nextPage"') + ->toContain('wire:key="managed-') + ->toContain('wire:key="unmanaged-') + ->not->toContain('$server->definedResources()'); +}); + +it('searches names across all pages before pagination on both tabs', function (string $tab, string $nameKey) { + $rows = collect(range(1, 25))->map(fn (int $id) => [$nameKey => "Resource $id"]); + $this->component->activeTab = $tab; + if ($tab === 'managed') { + $this->component->server->shouldReceive('definedResources')->andReturn($rows); + } else { + $this->component->unmanagedContainers = $rows->all(); + } + + $this->component->setPage(3); + $this->component->search = ' RESOURCE 2 '; + $this->component->updatedSearch(); + $page = $this->component->render()->getData()['resources']; + expect($page->currentPage())->toBe(1) + ->and($page->total())->toBe(7) + ->and($page->pluck($nameKey)->all())->toBe([ + 'Resource 2', 'Resource 20', 'Resource 21', 'Resource 22', + 'Resource 23', 'Resource 24', 'Resource 25', + ]); + + $this->component->search = 'missing'; + $this->component->updatedSearch(); + expect($this->component->render()->getData()['resources']->total())->toBe(0); + + $this->component->search = ''; + $this->component->updatedSearch(); + $page = $this->component->render()->getData()['resources']; + expect($page->total())->toBe(25)->and($page->currentPage())->toBe(1); + + $this->component->search = ' '; + expect($this->component->render()->getData()['resources']->total())->toBe(25); +})->with([['managed', 'name'], ['unmanaged', 'Names']]); + +it('provides accessible live search and a distinct no-results message', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + + expect($view)->toContain('wire:model.live.debounce.300ms="search"') + ->toContain('aria-label="Search resources by name"') + ->toContain('aria-label="Clear search"') + ->toContain('No matching resources') + ->toContain('No matching containers'); +}); + +it('shows search feedback and prevents interaction with stale results while searching', function () { + $view = file_get_contents(resource_path('views/livewire/server/resources.blade.php')); + + expect($view) + ->toContain('') + ->not->toContain('wire:loading.inline-flex wire:target="search"') + ->toContain('wire:loading.class="pointer-events-none opacity-40 blur-[2px]"') + ->toContain('wire:loading.attr="inert" wire:target="search"'); +}); diff --git a/tests/Feature/ServiceResourceRoutingTest.php b/tests/Feature/ServiceResourceRoutingTest.php index 9d5ac5a8a8..ede6fb6fc0 100644 --- a/tests/Feature/ServiceResourceRoutingTest.php +++ b/tests/Feature/ServiceResourceRoutingTest.php @@ -3,14 +3,17 @@ use App\Jobs\DatabaseBackupJob; use App\Jobs\VolumeBackupJob; use App\Livewire\Project\Database\Import as DatabaseImport; +use App\Livewire\Project\Service\BackupExecutions; use App\Livewire\Project\Service\Heading; use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use App\Models\Project; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; +use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -20,6 +23,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Once; @@ -234,6 +238,7 @@ test('service database backup schedules open in the Livewire component', functio test('service database backups can be queued from the Livewire component', function () { Queue::fake(); + $this->ownServiceDatabase->update(['status' => 'running:healthy']); $backup = ScheduledDatabaseBackup::create([ 'team_id' => $this->teamA->id, 'frequency' => 'daily', @@ -448,5 +453,294 @@ test('service storage backups page includes schedules from all compose databases ->assertSee('own-db') ->assertSee('analytics-db') ->assertSee("wire:click=\"openSchedule('{$backups->first()->uuid}')\"", false) - ->assertSee("wire:click=\"backupNow('database', '{$backups->first()->uuid}')\"", false); + ->assertSee("wire:click.stop=\"backupNow('database', '{$backups->first()->uuid}')\"", false); +}); + +test('service backup settings open automatically from the creation redirect', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertSet('scheduleModalOpen', true) + ->assertSet('selectedDatabaseBackup.uuid', $backup->uuid) + ->assertSee('S3'); +}); + +test('service backup settings reject a backup belonging to another team', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamB->id, + 'frequency' => 'daily', + 'database_id' => $this->otherServiceDatabase->id, + 'database_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + + $this->expectException(ModelNotFoundException::class); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]); +}); + +test('service backups have explicit settings actions for database and storage schedules', function () { + $databaseBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeBackup = $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + ]); + + $html = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])->html(); + $dom = new DOMDocument; + @$dom->loadHTML($html); + $xpath = new DOMXPath($dom); + $buttons = $xpath->query('//button[contains(., "Settings")]'); + $actions = []; + foreach ($buttons as $button) { + $actions[] = $button->getAttribute('wire:click.stop'); + } + + expect($actions)->toContain("openSchedule('{$databaseBackup->uuid}')", "openSchedule('{$volumeBackup->uuid}')"); + + foreach (['database' => $databaseBackup, 'storage' => $volumeBackup] as $type => $backup) { + $backupAction = "wire:click.stop=\"backupNow('{$type}', '{$backup->uuid}')\""; + $settingsAction = "wire:click.stop=\"openSchedule('{$backup->uuid}')\""; + expect(strpos($html, $backupAction))->toBeLessThan(strpos($html, $settingsAction)); + } + +}); + +test('members cannot open service backup settings', function () { + $this->userA->teams()->updateExistingPivot($this->teamA->id, ['role' => 'member']); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + Livewire::withQueryParams(['backup_uuid' => $backup->uuid]) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertForbidden(); +}); + +test('closing service backup settings clears the backup query parameter', function () { + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + + $component = Livewire::withQueryParams(['backup_uuid' => $backup->uuid, 'search' => 'own-db']) + ->test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertSet('scheduleModalOpen', true) + ->assertSet('backupUuid', $backup->uuid); + + expect($component->effects['url']['backupUuid']) + ->toMatchArray(['as' => 'backup_uuid', 'use' => 'replace', 'except' => '']); + + $component->dispatch('modalClosed') + ->assertSet('scheduleModalOpen', false) + ->assertSet('selectedDatabaseBackup', null) + ->assertSet('selectedVolumeBackup', null) + ->assertSet('backupUuid', '') + ->assertSet('search', 'own-db') + ->assertNoRedirect(); +}); + +test('service execution history paginates both backup types without truncating older runs', function () { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $databaseExecutions = collect(range(1, 105))->map(fn ($index) => ScheduledDatabaseBackupExecution::forceCreate([ + 'scheduled_database_backup_id' => $schedule->id, + 'status' => 'success', + 'created_at' => now()->subMinutes($index), + ])); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create(['team_id' => $this->teamA->id, 'frequency' => 'daily']); + $volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $volumeSchedule->id, + 'status' => 'success', + ]); + + $component = Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertViewHas('executions', function ($executions) use ($volumeExecution, $databaseExecutions) { + expect($executions)->toBeInstanceOf(LengthAwarePaginator::class) + ->and($executions->total())->toBe(106) + ->and($executions->count())->toBe(10) + ->and($executions->first()['uuid'])->toBe($volumeExecution->uuid) + ->and($executions->last()['uuid'])->toBe($databaseExecutions[8]->uuid); + + return true; + }) + ->assertSeeHtml('aria-label="Next page"'); + + $component->call('openExecution', $volumeExecution->uuid) + ->assertSet('selectedExecution.uuid', $volumeExecution->uuid) + ->call('closeExecutionModal'); + $component->call('nextPage', 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 2 && $executions->first()['uuid'] === $databaseExecutions[9]->uuid); + $component->call('setPage', 11, 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->count() === 6 && $executions->last()['uuid'] === $databaseExecutions->last()->uuid) + ->call('openExecution', $databaseExecutions->last()->uuid) + ->assertSet('executionModalOpen', true) + ->assertSet('selectedExecution.uuid', $databaseExecutions->last()->uuid); + $component->set('perPage', 25) + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 1 && $executions->count() === 25); + $component->call('setPage', 999, 'executionsPage') + ->assertViewHas('executions', fn ($executions) => $executions->currentPage() === 5 && $executions->count() === 6); + $component->set('perPage', 1000)->assertSet('perPage', 100); + $component->set('perPage', 0)->assertSet('perPage', 1); +}); + +test('service execution pagination excludes other teams and denies opening their runs', function (string $type) { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamB->id, + 'frequency' => 'daily', + 'database_id' => $this->otherServiceDatabase->id, + 'database_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $schedule->id, + 'status' => 'success', + ]); + + if ($type === 'storage') { + $volume = LocalPersistentVolume::create([ + 'name' => 'other-service-data', + 'mount_path' => '/data', + 'resource_id' => $this->otherServiceDatabase->id, + 'resource_type' => $this->otherServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create(['team_id' => $this->teamB->id, 'frequency' => 'daily']); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $volumeSchedule->id, + 'status' => 'success', + ]); + } + + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertViewHas('executions', fn ($executions) => $executions->isEmpty()) + ->assertDontSeeHtml('aria-label="Next page"') + ->call('openExecution', $execution->uuid) + ->assertNotFound(); +})->with(['database', 'storage']); + +test('execution page size remains adjustable when all runs fit on one page', function () { + $schedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + foreach (range(1, 11) as $index) { + $schedule->executions()->create(['status' => 'success']); + } + + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->set('perPage', 25) + ->assertSeeHtml('aria-label="Items per page"') + ->assertDontSeeHtml('aria-label="Next page"') + ->set('perPage', 10) + ->assertSeeHtml('aria-label="Next page"'); +}); + +test('service backup lists identify the configured S3 storage without extra columns', function () { + foreach (['Cloudflare R2', 'Railway S3', 'Maxio S3'] as $name) { + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data-'.str($name)->slug(), + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $storage = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => $name, 'bucket' => 'backups']); + ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + 'save_s3' => true, + 's3_storage_id' => $storage->id, + ]); + $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $storage->id, + ]); + } + + $html = Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])->html(); + foreach (['Cloudflare R2', 'Railway S3', 'Maxio S3'] as $name) { + expect(substr_count($html, 'data-tooltip="S3 storage: '.$name.' (bucket: backups)"'))->toBe(2); + } +}); + +test('execution tooltips distinguish current database storage from the recorded storage destination', function () { + $original = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => 'Cloudflare R2', 'bucket' => 'original']); + $current = S3Storage::create(['key' => 'key', 'secret' => 'secret', 'region' => 'auto', 'endpoint' => 'https://s3.example.com', 'team_id' => $this->teamA->id, 'name' => 'Railway S3', 'bucket' => 'current']); + $databaseSchedule = ScheduledDatabaseBackup::create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'database_id' => $this->ownServiceDatabase->id, + 'database_type' => $this->ownServiceDatabase->getMorphClass(), + 'save_s3' => true, + 's3_storage_id' => $current->id, + ]); + $databaseSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true]); + $volume = LocalPersistentVolume::create([ + 'name' => 'service-data', + 'mount_path' => '/data', + 'resource_id' => $this->ownServiceDatabase->id, + 'resource_type' => $this->ownServiceDatabase->getMorphClass(), + ]); + $volumeSchedule = $volume->scheduledBackups()->create([ + 'team_id' => $this->teamA->id, + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $current->id, + ]); + $volumeSchedule->executions()->create(['status' => 'success', 's3_uploaded' => true, 's3_storage_id' => $original->id]); + + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertSeeHtml('data-tooltip="Current schedule S3 storage: Railway S3 (bucket: current)"') + ->assertSeeHtml('data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"'); + + $current->update(['team_id' => $this->teamB->id]); + Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService]) + ->assertDontSee('Railway S3') + ->assertSeeHtml('data-tooltip="S3 storage: Unavailable"'); + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertDontSee('Railway S3') + ->assertSeeHtml('data-tooltip="Current schedule S3 storage: Unavailable"'); + + $databaseSchedule->update(['save_s3' => false]); + $original->delete(); + Livewire::test(BackupExecutions::class, ['service' => $this->ownService]) + ->assertSeeHtml('data-tooltip="Current schedule S3 storage: Not configured"') + ->assertDontSeeHtml('data-tooltip="S3 storage: Cloudflare R2 (bucket: original)"') + ->assertSeeHtml('data-tooltip="S3 storage: Unavailable"'); }); diff --git a/tests/Feature/SharedVariableDevViewTest.php b/tests/Feature/SharedVariableDevViewTest.php index 34767cf06d..9b434ef994 100644 --- a/tests/Feature/SharedVariableDevViewTest.php +++ b/tests/Feature/SharedVariableDevViewTest.php @@ -168,3 +168,38 @@ test('server shared variable dev view updates existing variable', function () { expect($var->value)->toBe('new_value') ->and($var->comment)->toBe('updated comment'); }); + +test('server shared variables display built-ins as read-only rows', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('COOLIFY_SERVER_UUID') + ->assertSee('COOLIFY_SERVER_NAME') + ->assertSee('Built-in · Read-only') + ->assertDontSee('Add a variable to make it available to resources in this scope.') + ->assertDontSee('data-env-settings-trigger', false) + ->assertSet('variables', '') + ->call('switch') + ->assertSee('COOLIFY_SERVER_UUID') + ->assertSee('COOLIFY_SERVER_NAME') + ->assertSet('variables', '') + ->set('variables', "COOLIFY_SERVER_UUID=changed\nCOOLIFY_SERVER_NAME=changed\nCUSTOM=value") + ->call('submit'); + + expect($server->environment_variables()->pluck('value', 'key')->all()) + ->toMatchArray(['COOLIFY_SERVER_UUID' => $server->uuid, 'COOLIFY_SERVER_NAME' => $server->name, 'CUSTOM' => 'value']); +}); + +test('server built-ins are visible to team members but not other teams', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('COOLIFY_SERVER_UUID') + ->assertDontSee('Add variable'); + + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + Livewire::test(App\Livewire\SharedVariables\Server\Show::class, ['server_uuid' => $otherServer->uuid]) + ->assertRedirect(route('dashboard')) + ->assertDontSee($otherServer->uuid); +}); From d75881fa96b49f28f00979a18457d9960f05b60a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:29:58 +0200 Subject: [PATCH 46/48] feat(domains): keep domain drafts and move preview settings Preserve in-progress domain edits and redirects across Livewire refreshes, copy www port overrides for service redirect pairs, and relocate preview deployment toggles from Advanced to Previews. Unsaved bars can stay dirty via Alpine while a modal is closed, and service domain tables stack at narrow widths. --- DESIGN.md | 5 +- app/Livewire/Project/Application/Advanced.php | 10 - app/Livewire/Project/Application/Domains.php | 25 +- app/Livewire/Project/Application/Previews.php | 22 ++ app/Livewire/Project/Service/Domains.php | 42 +++- resources/css/app.css | 60 +++++ .../views/components/unsaved-bar.blade.php | 5 +- .../project/application/advanced.blade.php | 14 +- .../project/application/domains.blade.php | 220 +++++++++-------- .../application/partials/domain-row.blade.php | 143 ++++------- .../project/application/previews.blade.php | 23 ++ .../project/service/domains.blade.php | 151 ++++++++---- .../service/partials/domain-table.blade.php | 131 ++++------ tests/Feature/ApplicationDomainsTest.php | 202 +++++++++++++--- .../ApplicationPreviewSettingsTest.php | 139 +++++++++++ tests/Feature/ServiceDomainsTest.php | 224 ++++++++++++++++-- .../Browser/ApplicationConfigurationTest.php | 85 +++++++ tests/v4/Browser/ServiceConfigurationTest.php | 129 ++++++++++ 18 files changed, 1204 insertions(+), 426 deletions(-) create mode 100644 tests/Feature/ApplicationPreviewSettingsTest.php diff --git a/DESIGN.md b/DESIGN.md index a7b26bb666..b27ed68f2d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -520,7 +520,10 @@ Do not restore the old full-width footer. Deferred fields in one Livewire component use one floating unsaved bar and one submit action. Do not add a separate “Save configuration” button to every card. Selectors that are safe to persist independently should use the existing -instant-save pattern. +instant-save pattern. When those requests share a component with a modal draft, +pass the unsaved bar a `dirty` Alpine expression comparing that draft with its +initial values, so unrelated saves do not hide pending changes. Mount modal save +bars only while the modal is open to avoid inactive keyboard shortcuts. --- diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 45e284c5dc..a9e1c0be28 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -27,12 +27,6 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitShallowCloneEnabled = false; - #[Validate(['boolean'])] - public bool $isPreviewDeploymentsEnabled = false; - - #[Validate(['boolean'])] - public bool $isPrDeploymentsPublicEnabled = false; - #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -107,8 +101,6 @@ class Advanced extends Component $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; - $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; - $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -136,8 +128,6 @@ class Advanced extends Component $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; - $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; - $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 80eb9bf0a8..906cc147a0 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -150,7 +150,15 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && ($row['service'] ?? null) === ($editingRow['service'] ?? null)); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -227,7 +235,9 @@ class Domains extends Component $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; - $this->redirect = $this->application->redirect ?? 'both'; + if ($this->pendingAction !== 'redirect' || $this->isCompose) { + $this->redirect = $this->application->redirect ?? 'both'; + } $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $settings = instanceSettings(); @@ -254,6 +264,9 @@ class Domains extends Component } $this->composeServices = []; + $pendingRedirect = $this->pendingRedirectService !== null + ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) + : null; $this->serviceRedirects = []; if ($this->isCompose) { try { @@ -290,7 +303,9 @@ class Domains extends Component $serviceEntry = $domains[$serviceName] ?? null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( - is_string($storedRedirect) ? $storedRedirect : null + $this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService + ? $pendingRedirect + : (is_string($storedRedirect) ? $storedRedirect : null) ); } } @@ -973,7 +988,13 @@ class Domains extends Component return; } + $this->authorize('update', $this->application); + $wasRedirect = $this->pendingAction === 'redirect'; $this->pendingAction = null; + $this->pendingRedirectService = null; + if ($wasRedirect) { + $this->refreshDomains(); + } } public function addDomain(): void diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index fa0272bd64..832a0d55cf 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -19,6 +19,10 @@ class Previews extends Component public Application $application; + public bool $isPreviewDeploymentsEnabled = false; + + public bool $isPrDeploymentsPublicEnabled = false; + public string $deployment_uuid; public array $parameters; @@ -41,11 +45,29 @@ class Previews extends Component public function mount() { + $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->pull_requests = collect(); $this->parameters = get_route_parameters(); $this->syncDockerTags(); } + public function savePreviewSettings(): void + { + $this->authorize('update', $this->application); + $this->validate([ + 'isPreviewDeploymentsEnabled' => 'boolean', + 'isPrDeploymentsPublicEnabled' => 'boolean', + ]); + + $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; + $this->application->settings->save(); + + $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); + } + private function syncDockerTags(): void { $this->previewDockerTags = []; diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4ac2f40b2b..b4f76d12d9 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -129,9 +129,17 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->service->refresh(); $this->service->load(['applications', 'server']); $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && (int) $row['service_application_id'] === (int) $editingRow['service_application_id']); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -239,9 +247,14 @@ class Domains extends Component ]) ->all(); + $pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null; $this->serviceRedirects = []; foreach ($this->service->applications as $app) { - $this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null); + $this->serviceRedirects[$app->id] = $this->normalizeRedirect( + $this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId + ? $pendingRedirect + : $app->redirect + ); } if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) { @@ -924,6 +937,7 @@ class Domains extends Component } $toAdd = collect(); + $portOverrides = $app->domain_port_overrides ?? []; foreach ($current as $url) { $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { @@ -940,6 +954,11 @@ class Domains extends Component continue; } + $port = $this->effectiveDomainInternalPort($url, $app); + if ($port['has_port_override']) { + $portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port']; + } + $knownHosts[$hostKey] = true; $toAdd->push($counterpart); } @@ -948,12 +967,13 @@ class Domains extends Component return true; } + $app->domain_port_overrides = $portOverrides ?: null; $merged = $current->merge($toAdd)->unique()->values(); $this->pendingAction = 'redirect'; $this->pendingRedirectServiceApplicationId = $app->id; - // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainListForApp($app, $merged)) { + // Counterparts inherit an existing port, so only domain conflicts need confirmation. + if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) { return false; } @@ -980,11 +1000,24 @@ class Domains extends Component return; } + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $this->setServiceRedirect($this->pendingRedirectServiceApplicationId); + + return; + } + $this->addDomain(); } public function cancelRemovePort(): void { + $this->authorize('update', $this->service); + + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $app = $this->findServiceApp($this->pendingRedirectServiceApplicationId); + $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect); + } + $this->pendingRedirectServiceApplicationId = null; $this->showPortWarningModal = false; $this->forceSaveDomains = false; $this->forceRemovePort = false; @@ -1421,6 +1454,7 @@ class Domains extends Component ServiceApplication $app, Collection $domains, bool $checkConflicts = true, + bool $checkPorts = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1447,7 +1481,7 @@ class Domains extends Component } } - if (! $this->forceRemovePort) { + if ($checkPorts && ! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { $previousFqdn = $app->getOriginal('fqdn'); diff --git a/resources/css/app.css b/resources/css/app.css index 65bce6327d..e3e20cf75e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4396,3 +4396,63 @@ a.command-palette-item:focus-visible { .dark .command-palette-arch-badge { color: #fcd34d; } + +/* Service domains prioritize public addresses; configuration lives in settings. */ +#service-domains-section, +.domains-overview-container { + container: service-domains / inline-size; +} + +.service-domains-overview-grid { + grid-template-columns: minmax(0, 1fr) 7.25rem 7.5rem 5.5rem 6.5rem 8rem 6.5rem; + column-gap: 0.75rem; +} + +.data-table-row.service-domains-overview-grid { + padding-block: 0.5rem; +} + +.service-domain-detail { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + font-size: 12px; +} + +.service-domains-overview-grid > span:not(:first-child):not(:last-child) { + text-align: center; +} + +.service-domain-detail-label { + display: none; +} + +.service-domains-https .listbox-trigger { + min-width: 7rem; +} + +@container service-domains (max-width: 980px) { + .data-table-header.service-domains-overview-grid { + display: none; + } + + .data-table-row.service-domains-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + + .data-table-row.service-domains-overview-grid > :first-child { + grid-column: 1 / -1; + } + + .service-domain-detail { + justify-content: space-between; + gap: 0.5rem; + } + + .service-domain-detail-label { + display: inline; + color: var(--coollabs-fg-dim); + } +} diff --git a/resources/views/components/unsaved-bar.blade.php b/resources/views/components/unsaved-bar.blade.php index 8d029c2fb3..cbc137e07b 100644 --- a/resources/views/components/unsaved-bar.blade.php +++ b/resources/views/components/unsaved-bar.blade.php @@ -5,6 +5,8 @@ // appears when those fields differ from the last server snapshot — not on // incidental component state (e.g. $wire.set from x-init, display-only props). 'targets' => null, + // Optional Alpine expression for drafts that survive unrelated server requests. + 'dirty' => null, ]) {{-- Floating "unsaved changes" pill (bottom center). Reveals itself via @@ -40,7 +42,8 @@ window.visualViewport?.removeEventListener('scroll', this.updateKeyboardInset); window.removeEventListener('resize', this.updateKeyboardInset); }, -}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty" +}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" + @if ($dirty) x-bind:class="{ 'is-dirty': {{ $dirty }} }" @else wire:dirty.class="is-dirty" @endif wire:loading.class="is-saving" @keydown.enter.window=" if ($el.classList.contains('is-dirty') && diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 429689ce7b..c6113808c5 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -55,7 +55,7 @@ @if ($application->git_based()) + helper="Automatic deployments from Git webhooks.">
true, 'label' => 'Deploy on push (webhooks)'], ['value' => false, 'label' => 'Manual deployments only'], ]" :disabled="! $canUpdate" /> - -
diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index b80085b9d5..93edafde59 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -6,26 +6,31 @@ $composeDomainGroups = collect($domainRows) ->groupBy(fn ($row) => $row['service'] ?? '__unknown') ->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false))); - $helperText = $isCompose - ? 'Manage domains for every service in this Docker Compose application.' - : 'Manage domains for this application.'; $hasHttpsDomains = collect($domainRows)->contains( fn ($row) => ! ($row['is_suggested'] ?? false) && str_starts_with(strtolower($row['url']), 'https://') ); @endphp - @endif - - @can('update', $application) - - - - Recheck DNS - - - @endcan + @if ($labelsAreWritable) + + Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. + + @endif - @if ($labelsAreWritable) - - Container label readonly mode is disabled. Domains must be set in the Labels section on the General page. - - @endif + @if ($isCompose && count($composeServices) === 0) + + No non-database services found in the Docker Compose file. Domains can only be assigned to application + services. + + @endif - @if ($isCompose && count($composeServices) === 0) - - No non-database services found in the Docker Compose file. Domains can only be assigned to application - services. - - @endif - - @cannot('update', $application) - - You don't have permission to manage domains. Contact your team administrator for access. - - @endcannot - -

- {{ $helperText }} -

- - @if ($hasHttpsDomains && ! $labelsAreWritable) -
- -
- @endif - -
+ @cannot('update', $application) + + You don't have permission to manage domains. Contact your team administrator for access. + + @endcannot {{-- Toolbar --}} -
+
+

Domains

{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} @if ($suggestedCount > 0) @@ -98,7 +76,7 @@

- @if ($isCompose && $composeDomainGroups->isNotEmpty()) + @if ($hasRows)
@@ -107,6 +85,10 @@
@endif @can('update', $application) + + + Check all DNS +
@include('livewire.project.shared.cloudflare-autoconfigure')
@@ -118,7 +100,7 @@ @@ -168,20 +150,46 @@
+ @if ($hasHttpsDomains && ! $labelsAreWritable) +
+ + +
+ +
+
+ @endif + {{-- Table / empty --}}
+ @if ($hasRows) +
+ Domain + Protocol redirect + Domain redirect + Internal port + Search indexing + DNS status + Actions +
+ @endif @if ($isCompose && count($composeServices) === 0 && ! $hasRows) @elseif ($isCompose && $composeDomainGroups->isEmpty()) @elseif (! $hasRows) @elseif ($isCompose) @php @@ -213,36 +221,10 @@ {{ $serviceName }} -
- - @if (auth()->user()?->can('update', $application) && ! $labelsAreWritable) - - @else - - {{ match ($serviceRedirects[$redirectWireKey] ?? 'both') { - 'www' => 'Redirect to www', - 'non-www' => 'Redirect to non-www', - default => 'Allow both', - } }} - - @endif -
-
-
- Domain - DNS Check - Search engine indexing - -
@foreach ($rows as $row) @php $index = collect($domainRows)->search( @@ -256,9 +238,7 @@ 'row' => $row, 'application' => $application, 'labelsAreWritable' => $labelsAreWritable, - 'isCompose' => false, - 'showDirectionControl' => false, - 'domainGridClass' => 'domains-table-grid-service', + 'isCompose' => true, ]) @endforeach
@@ -273,13 +253,6 @@
@else
-
- Domain - DNS Check - Search engine indexing - Direction - -
@foreach ($domainRows as $index => $row) @include('livewire.project.application.partials.domain-row', [ 'index' => $index, @@ -290,10 +263,15 @@ ]) @endforeach
+
+ +
@endif
- {{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}} + {{-- One dialog for address edits and automatically saved domain settings. --}}