From b154814d272a63d281d7686ee93c07cc85f0b16a Mon Sep 17 00:00:00 2001 From: pkonejero Date: Wed, 23 Sep 2026 12:00:20 +0200 Subject: [PATCH 1/2] fix(domains): allow a new dns check to replace a stored result Starting a re-check only marked the row as checking in memory, because persisting that state was rejected whenever a completed result was already stored. The queued job then refused to write its own result, so a domain kept its first status forever and a transient failure could never be cleared from the UI. --- app/Livewire/Project/Application/Domains.php | 15 +++-- app/Livewire/Project/Service/Domains.php | 16 +++-- tests/Feature/ApplicationDomainsTest.php | 61 ++++++++++++++++++++ tests/Feature/ServiceDomainsTest.php | 28 +++++++++ 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 929c02e93e..3903a7c6d4 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -775,8 +775,11 @@ class Domains extends Component /** * Persist current domain row DNS results and drop statuses for removed domains. + * + * @param array $startingCheckKeys Status keys whose check is being started by this call. + * They are allowed to replace a stored completed result. */ - protected function persistDomainDnsStatuses(): void + protected function persistDomainDnsStatuses(array $startingCheckKeys = []): void { $statuses = []; @@ -808,11 +811,15 @@ class Domains extends Component ]; } - DB::transaction(function () use (&$statuses): void { + DB::transaction(function () use (&$statuses, $startingCheckKeys): void { $application = Application::query()->lockForUpdate()->findOrFail($this->application->id); $storedStatuses = $application->domain_dns_statuses ?? []; foreach ($statuses as $key => $status) { + if (in_array($key, $startingCheckKeys, true)) { + continue; + } + $localCheckId = $status['check_id'] ?? null; $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; @@ -1054,7 +1061,7 @@ class Domains extends Component foreach ($dnsChecks as $dnsCheck) { $this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); } - $this->persistDomainDnsStatuses(); + $this->persistDomainDnsStatuses($dnsChecks->pluck('status_key')->all()); $failedDnsChecks = 0; foreach ($dnsChecks as $dnsCheck) { @@ -1207,7 +1214,7 @@ class Domains extends Component foreach ($this->dnsEntriesForUrls($urls, $service) as $statusKey => $url) { $checkId = new_public_id(); $this->markUrlsAsChecking([$url], $service, $checkId); - $this->persistDomainDnsStatuses(); + $this->persistDomainDnsStatuses([$statusKey]); try { CheckDomainDnsJob::dispatch( diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 79c89322a7..8e3402d291 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -577,7 +577,11 @@ class Domains extends Component $this->persistAllDomainDnsStatuses(); } - protected function persistAllDomainDnsStatuses(): void + /** + * @param array $startingCheckKeys Status keys whose check is being started by this call. + * They are allowed to replace a stored completed result. + */ + protected function persistAllDomainDnsStatuses(array $startingCheckKeys = []): void { $byApp = []; @@ -617,11 +621,15 @@ class Domains extends Component ->all(); $statuses = array_intersect_key($statuses, array_flip($currentUrls)); - DB::transaction(function () use ($app, &$statuses): void { + DB::transaction(function () use ($app, &$statuses, $startingCheckKeys): void { $application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id); $storedStatuses = $application->domain_dns_statuses ?? []; foreach ($statuses as $key => $status) { + if (in_array($key, $startingCheckKeys, true)) { + continue; + } + $localCheckId = $status['check_id'] ?? null; $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; @@ -1096,7 +1104,7 @@ class Domains extends Component foreach ($dnsChecks as $dnsCheck) { $this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); } - $this->persistAllDomainDnsStatuses(); + $this->persistAllDomainDnsStatuses($dnsChecks->pluck('url')->all()); $failedDnsChecks = 0; foreach ($dnsChecks as $dnsCheck) { @@ -1657,7 +1665,7 @@ class Domains extends Component foreach (array_unique($urls) as $url) { $checkId = new_public_id(); $this->markUrlsAsChecking([$url], (int) $application->id, $checkId); - $this->persistAllDomainDnsStatuses(); + $this->persistAllDomainDnsStatuses([$url]); try { CheckDomainDnsJob::dispatch( diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 74c9db670f..183073366b 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -3537,3 +3537,64 @@ it('prevents members from cancelling protected application redirect conflict sta ->set('showDomainConflictModal', false) ->assertForbidden(); }); + +it('restarts a dns check when the domain already has a completed result', function () { + Queue::fake(); + + $url = 'https://dns-recheck.example.com'; + $this->application->update([ + 'fqdn' => $url, + 'domain_dns_statuses' => [ + $url => [ + 'status' => 'failed', + 'message' => 'Required DNS record type A pointing to 203.0.113.10', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->subDay()->toIso8601String(), + ], + ], + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('checkDomainDns', 0) + ->assertSet('domainRows.0.dns_status', 'checking'); + + expect($this->application->fresh()->domain_dns_statuses[$url]['status'])->toBe('checking'); + + Queue::assertPushed(CheckDomainDnsJob::class); +}); + +it('does not overwrite a completed dns result with stale checking state', function () { + $url = 'https://dns-stale.example.com'; + $this->application->update([ + 'fqdn' => $url, + 'domain_dns_statuses' => [ + $url => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'check_id' => 'stale-check', + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + $this->application->update([ + 'domain_dns_statuses' => [ + $url => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'check_id' => 'completed-check', + ], + ], + ]); + + $method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses'); + $method->invoke($component->instance()); + + expect($this->application->fresh()->domain_dns_statuses[$url]) + ->toMatchArray([ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'check_id' => 'completed-check', + ]); +}); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index dc07e204de..8e74ded5fe 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -1319,3 +1319,31 @@ it('lays out the domain settings dropdowns in responsive columns', function () { expect($view)->toContain('mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2') ->toContain('flex flex-wrap items-center justify-between gap-2'); }); + +it('restarts a service dns check when the domain already has a completed result', function () { + Queue::fake(); + + $domain = 'https://api.example.com'; + $this->apiApp->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'failed', + 'message' => 'Required DNS record type A pointing to 203.0.113.10', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->subDay()->toIso8601String(), + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]); + $index = collect($component->get('domainRows'))->search(fn (array $row): bool => $row['url'] === $domain); + + expect($index)->not->toBeFalse(); + + $component->call('checkDomainDns', $index) + ->assertSet("domainRows.{$index}.dns_status", 'checking'); + + expect($this->apiApp->fresh()->domain_dns_statuses[$domain]['status'])->toBe('checking'); + + Queue::assertPushed(CheckDomainDnsJob::class); +}); From f276f9573f2cadc1f2b0d8ed8535425f149e8cf6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:12:27 +0200 Subject: [PATCH 2/2] fix(previews): allow re-checking DNS over a stored completed result Starting a DNS check for a preview domain used to leave the stored completed status in place. That blocked the new 'checking' state from being saved. persistDnsStatuses now takes the status keys whose checks are being started and lets those keys overwrite what is stored. Add tests for re-checking DNS on application domains and preview domains that already have a completed result. --- .../Project/Application/PreviewDomains.php | 15 +++-- tests/Feature/ApplicationDomainsTest.php | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 5f454d91ba..ee635f16b6 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -339,17 +339,18 @@ class PreviewDomains extends Component } $row = $this->domainRows[$index]; + $statusKey = $this->statusKey($row['url'], $row['service']); $checkId = new_public_id(); $this->domainRows[$index]['dns_status'] = 'checking'; $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; $this->domainRows[$index]['check_id'] = $checkId; - $this->persistDnsStatuses(); + $this->persistDnsStatuses([$statusKey]); try { $server = $this->preview->application->destination?->server; CheckDomainDnsJob::dispatch( $this->preview, - $this->statusKey($row['url'], $row['service']), + $statusKey, $row['url'], $server, $server ? serverDnsTargetIp($server) ?? $server->ip : null, @@ -494,7 +495,11 @@ class PreviewDomains extends Component return true; } - private function persistDnsStatuses(): void + /** + * @param array $startingCheckKeys Status keys whose check is being started by this call. + * They are allowed to replace a stored completed result. + */ + private function persistDnsStatuses(array $startingCheckKeys = []): void { $statuses = []; foreach ($this->domainRows as $row) { @@ -505,13 +510,13 @@ class PreviewDomains extends Component ]; } - DB::transaction(function () use (&$statuses): void { + DB::transaction(function () use (&$statuses, $startingCheckKeys): void { $preview = ApplicationPreview::query()->lockForUpdate()->findOrFail($this->preview->id); $storedStatuses = $preview->domain_dns_statuses ?? []; foreach ($statuses as $key => $status) { $storedStatus = $storedStatuses[$key] ?? null; - if (! is_array($storedStatus)) { + if (! is_array($storedStatus) || in_array($key, $startingCheckKeys, true)) { continue; } diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 183073366b..4bc05e018a 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -3598,3 +3598,59 @@ it('does not overwrite a completed dns result with stale checking state', functi 'check_id' => 'completed-check', ]); }); + +it('replaces a completed dns result when the domain is checked again', function () { + $settings = InstanceSettings::get(); + $settings->is_dns_validation_enabled = true; + $settings->save(); + + $url = 'https://this-domain-should-not-resolve-for-coolify-tests.invalid'; + $checkedAt = now()->subDays(12)->toIso8601String(); + $this->application->update([ + 'fqdn' => $url, + 'domain_dns_statuses' => [ + $url => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => $checkedAt, + ], + ], + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('checkDomainDns', 0) + ->call('pollDnsChecks') + ->assertSet('domainRows.0.dns_status', 'failed'); + + expect($this->application->fresh()->domain_dns_statuses[$url]) + ->status->toBe('failed') + ->checked_at->not->toBe($checkedAt); +}); + +it('restarts a preview dns check when the domain already has a completed result', function () { + Queue::fake(); + + $url = 'https://preview-recheck.example.com'; + $statusKey = hash('sha256', $url.'|'); + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 54, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/54', + 'fqdn' => $url, + 'domain_dns_statuses' => [ + $statusKey => [ + 'status' => 'failed', + 'message' => 'Required DNS record type A pointing to 203.0.113.10', + ], + ], + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('checkDomainDns', 0) + ->assertSet('domainRows.0.dns_status', 'checking'); + + expect($preview->fresh()->domain_dns_statuses[$statusKey]['status'])->toBe('checking'); + + Queue::assertPushed(CheckDomainDnsJob::class); +});