From c9fcc0bc4444b71d4a096608c6ae28284b034c37 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 31 May 2026 21:19:18 +0200 Subject: [PATCH 1/3] fix(service): defer stop when pulling latest images Ensure restart actions flow through StartService so pull-latest restarts can avoid stopping the service before image pulls. Also raise the changelog modal above the desktop sidebar toggle. --- app/Actions/Service/RestartService.php | 8 ++-- app/Actions/Service/StartService.php | 7 ++- package-lock.json | 8 +--- .../livewire/settings-dropdown.blade.php | 2 +- tests/Feature/SettingsDropdownTest.php | 44 +++++++++++++++++++ .../StartServicePullLatestRestartTest.php | 36 +++++++++++++++ 6 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/SettingsDropdownTest.php create mode 100644 tests/Unit/Service/StartServicePullLatestRestartTest.php diff --git a/app/Actions/Service/RestartService.php b/app/Actions/Service/RestartService.php index d38ef54d6..6acd3b0a4 100644 --- a/app/Actions/Service/RestartService.php +++ b/app/Actions/Service/RestartService.php @@ -13,8 +13,10 @@ class RestartService public function handle(Service $service, bool $pullLatestImages) { - StopService::run($service); - - return StartService::run($service, $pullLatestImages); + return StartService::run( + service: $service, + pullLatestImages: $pullLatestImages, + stopBeforeStart: true, + ); } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index d3d99ff78..89817506d 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -19,7 +19,7 @@ class StartService public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); - if ($stopBeforeStart) { + if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) { StopService::run(service: $service, dockerCleanup: false); } $service->saveComposeConfigs(); @@ -53,4 +53,9 @@ class StartService return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); } + + private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool + { + return $stopBeforeStart && ! $pullLatestImages; + } } diff --git a/package-lock.json b/package-lock.json index bcacecc8b..9d495c412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1261,8 +1261,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/clsx": { "version": "2.1.1", @@ -1752,7 +1751,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1946,8 +1944,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -1992,7 +1989,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php index 0c0c000d5..d40d6778e 100644 --- a/resources/views/livewire/settings-dropdown.blade.php +++ b/resources/views/livewire/settings-dropdown.blade.php @@ -198,7 +198,7 @@ @if ($showWhatsNewModal) -
Release notes
', + 'published_at' => Carbon::parse('2026-05-01'), + 'is_read' => false, + ], + ]); + } + + public function getUnreadCountForUser(User $user): int + { + return 1; + } + }); + + Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar']) + ->call('openWhatsNewModal') + ->assertSee('Changelog') + ->assertSee('z-[60]', false) + ->assertSee('closeWhatsNewModal', false); +}); diff --git a/tests/Unit/Service/StartServicePullLatestRestartTest.php b/tests/Unit/Service/StartServicePullLatestRestartTest.php new file mode 100644 index 000000000..ce138ba65 --- /dev/null +++ b/tests/Unit/Service/StartServicePullLatestRestartTest.php @@ -0,0 +1,36 @@ +invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse(); +}); + +it('still stops a service before a regular restart', function () { + $method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting'); + + expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue() + ->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse(); +}); + +it('routes service restart actions through start service with deferred stop semantics', function () { + $service = Mockery::mock(Service::class); + + $stopService = Mockery::mock(StopService::class); + $stopService->shouldNotReceive('handle'); + app()->instance(StopService::class, $stopService); + + $startService = Mockery::mock(StartService::class); + $startService->shouldReceive('handle') + ->once() + ->with($service, true, true) + ->andReturn('restart queued'); + app()->instance(StartService::class, $startService); + + expect(RestartService::run($service, true))->toBe('restart queued'); +}); From 34f15c106c003645bf8cc0b6ba428e589ff1cbe5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 31 May 2026 21:46:23 +0200 Subject: [PATCH 2/3] fix(webhook): match GitLab SSH repos with custom ports Strip leading port segments from scp-style GitLab repository URLs so manual webhook matching compares the repository path consistently. Cover both ported and unported SSH URL forms. --- .../MatchesManualWebhookApplications.php | 4 ++ tests/Feature/Webhook/WebhookHmacTest.php | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index f1fd0c40f..0463790eb 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -81,6 +81,10 @@ trait MatchesManualWebhookApplications $path = data_get($parts, 'path'); } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { $path = Str::after($gitRepository, ':'); + // scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo". + // Strip the leading numeric port segment so the path matches the webhook + // payload's owner/repo, consistent with convertGitUrl() in shared.php. + $path = preg_replace('#^\d+/#', '', $path) ?? $path; } else { $path = $gitRepository; } diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index be2417462..3f49ff43d 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -509,6 +509,48 @@ describe('Manual Webhook Repository Matching', function () { expect($response->getContent())->not->toContain('No applications found'); }); + test('gitlab matches scp-style ssh repository URL with custom port', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'git@gitlab.example.com:2222/services/xyz.git', + 'git_branch' => 'master', + ]); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/master', + 'project' => ['path_with_namespace' => 'services/xyz'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); + + test('gitlab matches scp-style ssh repository URL without port', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'git@gitlab.example.com:services/xyz.git', + 'git_branch' => 'master', + ]); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/master', + 'project' => ['path_with_namespace' => 'services/xyz'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); + test('github matches repository case-insensitively', function () { $app = createApplicationWithWebhook(overrides: [ 'git_repository' => 'https://github.com/Test-Org/Test-Repo.git', From 1b68f11ec0864f3de0a7dac017b2fd68aec0d6f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 31 May 2026 21:47:18 +0200 Subject: [PATCH 3/3] fix(cleanup): disable unreachable self-hosted servers Preserve self-hosted server IPs during unreachable cleanup and force-disable them instead. Keep cloud cleanup behavior overwriting the IP, with test coverage for both paths. --- .../Commands/CleanupUnreachableServers.php | 10 +++++-- .../Feature/CleanupUnreachableServersTest.php | 28 ++++++++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index 09563a2c3..666e98a18 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -18,9 +18,13 @@ class CleanupUnreachableServers extends Command if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; - $server->update([ - 'ip' => '1.2.3.4', - ]); + if (isCloud()) { + $server->update([ + 'ip' => '1.2.3.4', + ]); + } else { + $server->forceDisableServer(); + } } } } diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php index c06944969..8849b1ca0 100644 --- a/tests/Feature/CleanupUnreachableServersTest.php +++ b/tests/Feature/CleanupUnreachableServersTest.php @@ -6,7 +6,30 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -it('cleans up servers with unreachable_count >= 3 after 7 days', function () { +it('disables (non-destructively) self-hosted servers with unreachable_count >= 3 after 7 days', function () { + config(['constants.coolify.self_hosted' => true]); + + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 50, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = (string) $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + // IP must be preserved — never overwritten on self-hosted. + expect($server->ip)->toBe($originalIp); + expect($server->settings->force_disabled)->toBeTrue(); +}); + +it('overwrites the IP with 1.2.3.4 on cloud for servers with unreachable_count >= 3 after 7 days', function () { + config(['constants.coolify.self_hosted' => false]); + $team = Team::factory()->create(); $server = Server::factory()->create([ 'team_id' => $team->id, @@ -36,6 +59,7 @@ it('does not clean up servers with unreachable_count less than 3', function () { $server->refresh(); expect($server->ip)->toBe($originalIp); + expect($server->settings->force_disabled)->toBeFalse(); }); it('does not clean up servers updated within 7 days', function () { @@ -53,6 +77,7 @@ it('does not clean up servers updated within 7 days', function () { $server->refresh(); expect($server->ip)->toBe($originalIp); + expect($server->settings->force_disabled)->toBeFalse(); }); it('does not clean up servers without notification sent', function () { @@ -70,4 +95,5 @@ it('does not clean up servers without notification sent', function () { $server->refresh(); expect($server->ip)->toBe($originalIp); + expect($server->settings->force_disabled)->toBeFalse(); });