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/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/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..4bc05e018a 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -3537,3 +3537,120 @@ 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', + ]); +}); + +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); +}); 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); +});