Merge remote-tracking branch 'origin/next' into terminal-long-session-disconnects

This commit is contained in:
Andras Bacsai
2026-06-01 06:56:11 +02:00
9 changed files with 172 additions and 9 deletions
+5 -3
View File
@@ -13,8 +13,10 @@ class RestartService
public function handle(Service $service, bool $pullLatestImages) public function handle(Service $service, bool $pullLatestImages)
{ {
StopService::run($service); return StartService::run(
service: $service,
return StartService::run($service, $pullLatestImages); pullLatestImages: $pullLatestImages,
stopBeforeStart: true,
);
} }
} }
+6 -1
View File
@@ -19,7 +19,7 @@ class StartService
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{ {
$service->parse(); $service->parse();
if ($stopBeforeStart) { if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
StopService::run(service: $service, dockerCleanup: false); StopService::run(service: $service, dockerCleanup: false);
} }
$service->saveComposeConfigs(); $service->saveComposeConfigs();
@@ -53,4 +53,9 @@ class StartService
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
} }
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
{
return $stopBeforeStart && ! $pullLatestImages;
}
} }
@@ -18,9 +18,13 @@ class CleanupUnreachableServers extends Command
if ($servers->count() > 0) { if ($servers->count() > 0) {
foreach ($servers as $server) { foreach ($servers as $server) {
echo "Cleanup unreachable server ($server->id) with name $server->name"; echo "Cleanup unreachable server ($server->id) with name $server->name";
$server->update([ if (isCloud()) {
'ip' => '1.2.3.4', $server->update([
]); 'ip' => '1.2.3.4',
]);
} else {
$server->forceDisableServer();
}
} }
} }
} }
@@ -81,6 +81,10 @@ trait MatchesManualWebhookApplications
$path = data_get($parts, 'path'); $path = data_get($parts, 'path');
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
$path = Str::after($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 { } else {
$path = $gitRepository; $path = $gitRepository;
} }
@@ -198,7 +198,7 @@
<!-- What's New Modal --> <!-- What's New Modal -->
@if ($showWhatsNewModal) @if ($showWhatsNewModal)
<div class="fixed inset-0 z-50 flex items-center justify-center py-6 px-4" <div class="fixed inset-0 z-[60] flex items-center justify-center py-6 px-4"
@keydown.escape.window="$wire.closeWhatsNewModal()"> @keydown.escape.window="$wire.closeWhatsNewModal()">
<!-- Background overlay --> <!-- Background overlay -->
<div class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs" wire:click="closeWhatsNewModal"> <div class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs" wire:click="closeWhatsNewModal">
@@ -6,7 +6,30 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); 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(); $team = Team::factory()->create();
$server = Server::factory()->create([ $server = Server::factory()->create([
'team_id' => $team->id, 'team_id' => $team->id,
@@ -36,6 +59,7 @@ it('does not clean up servers with unreachable_count less than 3', function () {
$server->refresh(); $server->refresh();
expect($server->ip)->toBe($originalIp); expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
}); });
it('does not clean up servers updated within 7 days', function () { 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(); $server->refresh();
expect($server->ip)->toBe($originalIp); expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
}); });
it('does not clean up servers without notification sent', function () { 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(); $server->refresh();
expect($server->ip)->toBe($originalIp); expect($server->ip)->toBe($originalIp);
expect($server->settings->force_disabled)->toBeFalse();
}); });
+44
View File
@@ -0,0 +1,44 @@
<?php
use App\Livewire\SettingsDropdown;
use App\Models\User;
use App\Services\ChangelogService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
it('renders the changelog modal above the desktop sidebar toggle', function () {
$user = new User(['email' => 'test@example.com']);
$user->id = 1;
Auth::setUser($user);
app()->instance(ChangelogService::class, new class extends ChangelogService
{
public function getEntriesForUser(User $user): Collection
{
return collect([
(object) [
'tag_name' => 'v1.0.0',
'title' => 'Test Release',
'content' => 'Release notes',
'content_html' => '<p>Release notes</p>',
'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);
});
+42
View File
@@ -509,6 +509,48 @@ describe('Manual Webhook Repository Matching', function () {
expect($response->getContent())->not->toContain('No applications found'); 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 () { test('github matches repository case-insensitively', function () {
$app = createApplicationWithWebhook(overrides: [ $app = createApplicationWithWebhook(overrides: [
'git_repository' => 'https://github.com/Test-Org/Test-Repo.git', 'git_repository' => 'https://github.com/Test-Org/Test-Repo.git',
@@ -0,0 +1,36 @@
<?php
use App\Actions\Service\RestartService;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Models\Service;
it('does not stop a service before pulling latest images', function () {
$method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting');
expect($method->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');
});