From eb57a691cac2faceb0f9c0aa91bcd25e9bed8939 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:22:04 +0200 Subject: [PATCH] feat(domains): add asynchronous DNS validation Add queued DNS checks with polling, status indicators, and notifications for application and service domains. Improve volume backup target labels and names. --- app/Actions/Shared/CheckDomainDns.php | 142 ++++++++++ app/Jobs/CheckDomainDnsJob.php | 90 ++++++ app/Livewire/Project/Application/Domains.php | 266 ++++++++++++++---- app/Livewire/Project/Service/Domains.php | 225 ++++++++++++--- .../Project/Service/VolumeBackup/Create.php | 8 +- .../project/application/domains.blade.php | 4 + .../application/partials/domain-row.blade.php | 1 + .../project/service/domains.blade.php | 4 + .../service/partials/domain-table.blade.php | 1 + tests/Feature/ApplicationDomainsTest.php | 130 +++++++-- tests/Feature/CheckDomainDnsJobTest.php | 119 ++++++++ tests/Feature/DnsValidationTest.php | 55 ++++ tests/Feature/ServiceDomainsTest.php | 60 +++- tests/Feature/VolumeBackupTest.php | 41 +++ 14 files changed, 1016 insertions(+), 130 deletions(-) create mode 100644 app/Actions/Shared/CheckDomainDns.php create mode 100644 app/Jobs/CheckDomainDnsJob.php create mode 100644 tests/Feature/CheckDomainDnsJobTest.php diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php new file mode 100644 index 000000000..d0cea0fb1 --- /dev/null +++ b/app/Actions/Shared/CheckDomainDns.php @@ -0,0 +1,142 @@ + $entries + * @return array + */ + public function handle( + array $entries, + ?Server $server, + ?string $expectedIp, + bool $skipForMultipleServers = false, + int $timeoutSeconds = 5, + ): array { + if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) { + return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp); + } + + if (! $server) { + return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null); + } + + if ($skipForMultipleServers) { + return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp); + } + + $deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000); + $dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers')) + ->explode(',') + ->map(fn ($dnsServer) => trim((string) $dnsServer)) + ->filter() + ->values(); + $results = []; + + foreach ($entries as $key => $url) { + $results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline); + } + + return $results; + } + + /** + * @param array $dnsServers + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array + { + try { + $host = Url::fromString($url)->getHost(); + } catch (\Throwable) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + if (str($host)->contains('sslip.io')) { + return $this->result('ok', 'DNS looks correct.', $expectedIp); + } + + $type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; + + foreach ($dnsServers as $dnsServer) { + $remainingNanoseconds = $deadline - hrtime(true); + if ($remainingNanoseconds < 1_000_000_000) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + + try { + $query = app()->make(DNSQuery::class, [ + 'server' => $dnsServer, + 'port' => 53, + 'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)), + ]); + $records = $query->query($host, $type); + + if ($records === false || $query->hasError()) { + continue; + } + + foreach ($records as $record) { + if ($record->getType() !== $type) { + continue; + } + + if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) { + return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); + } + } + } catch (\Throwable) { + continue; + } + } + + return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp); + } + + private function successMessage(Server $server, ?string $expectedIp): string + { + if ( + filled($expectedIp) + && filled($server->ip) + && $server->ip !== $expectedIp + && filter_var($server->ip, FILTER_VALIDATE_IP) === false + ) { + return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare)."; + } + + return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.'; + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function result(string $status, string $message, ?string $expectedIp): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $entries + * @return array + */ + private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array + { + $result = $this->result($status, $message, $expectedIp); + + return array_fill_keys(array_keys($entries), $result); + } +} diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php new file mode 100644 index 000000000..1a7ceaeab --- /dev/null +++ b/app/Jobs/CheckDomainDnsJob.php @@ -0,0 +1,90 @@ +persistResults(CheckDomainDns::run( + [$this->statusKey => $this->url], + $this->server, + $this->expectedIp, + $this->skipForMultipleServers, + )); + } + + public function failed(?\Throwable $exception): void + { + $this->persistResults([ + $this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'), + ]); + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function status(string $status, string $message): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $this->expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $results + */ + private function persistResults(array $results): void + { + DB::transaction(function () use ($results): void { + $resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey()); + if (! $resource) { + return; + } + + $statuses = $resource->domain_dns_statuses ?? []; + + foreach ($results as $key => $result) { + if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) { + continue; + } + + $statuses[$key] = $result; + } + + $resource->domain_dns_statuses = $statuses === [] ? null : $statuses; + $resource->save(); + }); + } +} diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 45a76a4c3..2f9370871 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Application; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; @@ -10,6 +12,7 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Domains extends Component @@ -141,6 +144,39 @@ public function refreshDomains(): void $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->application); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && ($row['service'] ?? null) === ($checkingRow['service'] ?? null)); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(string $domain, string|bool $indexing): void { $this->authorize('update', $this->application); @@ -464,6 +500,7 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'checked_at' => data_get($entry, 'checked_at'), + 'check_id' => data_get($entry, 'check_id'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -478,6 +515,7 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -533,6 +571,8 @@ public function checkAllDns(): void || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $reason = ! $this->dnsValidationEnabled @@ -548,7 +588,11 @@ public function checkAllDns(): void continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -575,45 +619,50 @@ public function checkDomainDns(int $index): void return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; + } + + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + + // Keep suggested-row copy short after DNS checks (no role badge). + if ($this->domainRows[$index]['is_suggested'] ?? false) { + $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); + $serviceName = $this->domainRows[$index]['service'] ?? null; + $meta = $this->suggestedDomainMeta( + $isWww, + $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) + ); + $this->domainRows[$index]['dns_message'] = $meta['pending_message']; + $this->domainRows[$index]['suggestion_label'] = null; + $this->domainRows[$index]['suggestion_role'] = $meta['role']; } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; - } - // Keep suggested-row copy short after DNS checks (no role badge). - if ($this->domainRows[$index]['is_suggested'] ?? false) { - $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); - $serviceName = $this->domainRows[$index]['service'] ?? null; - $meta = $this->suggestedDomainMeta( - $isWww, - $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) - ); - $this->domainRows[$index]['dns_message'] = $meta['pending_message']; - $this->domainRows[$index]['suggestion_label'] = null; - $this->domainRows[$index]['suggestion_role'] = $meta['role']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; } - - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); } /** @@ -647,11 +696,34 @@ protected function persistDomainDnsStatuses(): void 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } + DB::transaction(function () use (&$statuses): void { + $application = Application::query()->lockForUpdate()->findOrFail($this->application->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $this->application->domain_dns_statuses = $statuses === [] ? null : $statuses; - $this->application->save(); } protected function pruneDomainDnsStatusesToCurrentDomains(): void @@ -804,16 +876,6 @@ public function addDomain(): void } } - if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; if (! $this->saveDomainList($merged, $this->newDomainService)) { @@ -825,14 +887,110 @@ public function addDomain(): void $serviceForCheck = $this->newDomainService; $this->resetAddDomainForm(); $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck)) + ->map(fn (string $url, string $statusKey) => [ + 'status_key' => $statusKey, + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + $this->persistDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $this->application, + $dnsCheck['status_key'], + $dnsCheck['url'], + $this->application->destination?->server, + $this->serverIp, + $dnsCheck['check_id'], + $this->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $service, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + + /** + * @param array $urls + * @return array + */ + protected function dnsEntriesForUrls(array $urls, ?string $service = null): array + { + $entries = []; + + foreach ($urls as $url) { + $entries[$this->domainDnsStatusKey($url, $service)] = $url; + } + + return $entries; + } + /** * Run a first-time DNS check for newly added/updated domain URLs and persist results. * @@ -875,7 +1033,11 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -909,15 +1071,11 @@ protected function findDnsFailureMessage(array $urls): ?string return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4690335d8..d932e7649 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Service; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Server; @@ -131,6 +133,39 @@ public function refreshDomains(): void $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->service); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void { $application = $this->service->applications()->findOrFail($serviceApplicationId); @@ -282,6 +317,7 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'checked_at' => data_get($entry, 'checked_at'), + 'check_id' => data_get($entry, 'check_id'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -298,6 +334,7 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -404,6 +441,8 @@ public function checkAllDns(): void $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $this->domainRows[$index]['dns_status'] = 'skipped'; @@ -415,7 +454,11 @@ public function checkAllDns(): void continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -443,33 +486,37 @@ public function checkDomainDns(int $index): void return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistAllDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; } - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->decorateSuggestedDomainAfterDnsCheck($index); + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; + $this->decorateSuggestedDomainAfterDnsCheck($index); + } } /** @@ -516,6 +563,7 @@ protected function persistAllDomainDnsStatuses(): void 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } @@ -528,8 +576,30 @@ protected function persistAllDomainDnsStatuses(): void ->all(); $statuses = array_intersect_key($statuses, array_flip($currentUrls)); + DB::transaction(function () use ($app, &$statuses): void { + $application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $app->domain_dns_statuses = $statuses === [] ? null : $statuses; - $app->save(); } $this->service->load('applications'); @@ -928,16 +998,6 @@ public function addDomain(): void } } - if (! $this->forceSaveDns && $this->shouldValidateDns()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; @@ -955,14 +1015,93 @@ public function addDomain(): void $this->forceRemovePort = false; $this->pendingAction = null; $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $serviceApplicationId = (int) $app->id; + $dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [ + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + $this->persistAllDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $app, + $dnsCheck['url'], + $dnsCheck['url'], + $this->service->server, + $this->serverIp, + $dnsCheck['check_id'], + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistAllDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + public function startEdit(int $index): void { if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) { @@ -1309,7 +1448,11 @@ protected function checkUrlsDns(array $urls, ?int $serviceApplicationId = null): continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -1330,15 +1473,11 @@ protected function findDnsFailureMessage(array $urls): ?string return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Create.php b/app/Livewire/Project/Service/VolumeBackup/Create.php index adb1234e6..430e89a2c 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Create.php +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -99,8 +99,8 @@ private function availableTargets(): Collection $label = str($resource->name)->headline(); $targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [ 'key' => 'volume:'.$volume->id, - 'type' => 'Volume · '.$label, - 'name' => $volume->name, + 'type' => $label, + 'name' => str($volume->name)->after($this->service->uuid.'_')->value(), ])); $targets->push(...$resource->fileStorages() ->where('is_directory', true) @@ -109,8 +109,8 @@ private function availableTargets(): Collection ->get() ->map(fn (LocalFileVolume $directory): array => [ 'key' => 'directory:'.$directory->id, - 'type' => 'Directory · '.$label, - 'name' => $directory->fs_path, + 'type' => $label, + 'name' => $directory->fs_path.' (directory)', ])); } diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index 4adbcf8ff..128f2f262 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -2,6 +2,7 @@ $configuredCount = collect($domainRows)->where('is_suggested', false)->count(); $suggestedCount = collect($domainRows)->where('is_suggested', true)->count(); $hasRows = count($domainRows) > 0; + $hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'); $composeDomainGroups = collect($domainRows) ->groupBy(fn ($row) => $row['service'] ?? '__unknown') ->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false))); @@ -36,6 +37,9 @@ }" @open-edit-domain.window="openEditDomain()" @edit-domain-saved.window="closeEditDomain()"> + @if ($hasDnsChecksInProgress) + + @endif @can('update', $application) diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index 10aa21200..6c20d0a46 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -10,6 +10,7 @@ 'ok' => 'DNS OK', 'failed' => 'DNS mismatch', 'skipped' => 'DNS skipped', + 'checking' => 'Checking DNS...', 'pending' => 'DNS pending', default => 'DNS unknown', }; diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index 05e5792fe..c228f39e5 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -2,6 +2,7 @@ $configuredCount = collect($domainRows)->where('is_suggested', false)->count(); $suggestedCount = collect($domainRows)->where('is_suggested', true)->count(); $hasRows = count($domainRows) > 0; + $hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'); $serviceAppCount = count($serviceApps); $domainGroups = collect($domainRows) ->groupBy('service_application_id') @@ -37,6 +38,9 @@ }" @open-edit-domain.window="openEditDomain()" @edit-domain-saved.window="closeEditDomain()"> + @if ($hasDnsChecksInProgress) + + @endif @can('update', $service) diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 81daa100f..f856285ad 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -35,6 +35,7 @@ 'ok' => 'DNS OK', 'failed' => 'DNS mismatch', 'skipped' => 'DNS skipped', + 'checking' => 'Checking DNS...', 'pending' => 'DNS pending', default => 'DNS unknown', }; diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 50c71fe95..cac3b29a3 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -1,5 +1,6 @@ call('addDomain') ->assertHasNoErrors() ->assertSet('addDomainDnsFailed', false) - ->assertDispatched('success') + ->assertDispatched('success', 'Domain added. DNS check started.') ->assertDispatched('close-modal'); $this->application->refresh(); @@ -290,7 +292,9 @@ ->toContain('https://api.example.com'); }); -it('blocks adding a domain with bad dns until the user continues', function () { +it('saves a domain before checking dns in a separate request', function () { + Queue::fake(); + $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); @@ -298,15 +302,8 @@ $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') ->call('addDomain') - ->assertSet('addDomainDnsFailed', true) - ->assertSee('DNS is not pointing to the right IP') - ->assertSee('Are you sure you want to add it anyway'); - - $this->application->refresh(); - expect($this->application->fqdn)->toBeNull(); - - $component->call('confirmAddDomainDespiteDns') ->assertSet('addDomainDnsFailed', false) + ->assertSet('domainRows.0.dns_status', 'checking') ->assertDispatched('success') ->assertDispatched('close-modal'); @@ -315,17 +312,24 @@ 'https://this-domain-should-not-resolve-for-coolify-tests.invalid', 'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid', ]); + + expect($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null) + ->toBe('checking'); + + Queue::assertPushed(CheckDomainDnsJob::class, 2); + + $jobs = Queue::pushed(CheckDomainDnsJob::class); + + expect($jobs->pluck('statusKey')->all())->toEqualCanonicalizing([ + 'https://this-domain-should-not-resolve-for-coolify-tests.invalid', + 'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid', + ])->and($jobs->pluck('checkId')->unique())->toHaveCount(2); }); it('resets the dns gate when the domain input changes', function () { - $settings = InstanceSettings::get(); - $settings->is_dns_validation_enabled = true; - $settings->save(); - Livewire::test(Domains::class, ['application' => $this->application->fresh()]) - ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') - ->call('addDomain') - ->assertSet('addDomainDnsFailed', true) + ->set('addDomainDnsFailed', true) + ->set('forceSaveDns', true) ->set('newDomain', 'https://another.example.com') ->assertSet('addDomainDnsFailed', false) ->assertSet('forceSaveDns', false); @@ -727,6 +731,98 @@ ->and($entry['checked_at'] ?? null)->not->toBeNull(); }); +it('polls a queued dns check and notifies about a mismatch', function () { + $domain = 'https://app.example.com'; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSee('Checking DNS...') + ->assertSee('wire:poll.2000ms="pollDnsChecks"', false); + + $this->application->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()->toIso8601String(), + ], + ], + ]); + + $component->call('pollDnsChecks') + ->assertSet('domainRows.0.dns_status', 'failed') + ->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record.'); +}); + +it('does not overwrite a completed queued dns result with stale checking state', function () { + $domain = 'https://app.example.com'; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + $this->application->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->toIso8601String(), + ], + ], + ]); + + $method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses'); + $method->invoke($component->instance()); + + expect($this->application->fresh()->domain_dns_statuses[$domain]['status'])->toBe('ok'); +}); + +it('does not overwrite a newer queued dns check with stale completed component state', function () { + $domain = 'https://app.example.com'; + $status = [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + 'check_id' => null, + ]; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [$domain => $status], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + $status['check_id'] = 'newer-check'; + $this->application->update(['domain_dns_statuses' => [$domain => $status]]); + + $method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses'); + $method->invoke($component->instance()); + + expect($this->application->fresh()->domain_dns_statuses[$domain]['check_id'])->toBe('newer-check'); +}); + it('resolves hostname server addresses to a real ip for dns messages', function () { $this->server->update(['ip' => 'localhost']); $this->application->update([ diff --git a/tests/Feature/CheckDomainDnsJobTest.php b/tests/Feature/CheckDomainDnsJobTest.php new file mode 100644 index 000000000..74502268a --- /dev/null +++ b/tests/Feature/CheckDomainDnsJobTest.php @@ -0,0 +1,119 @@ + CheckDomainDns::clearFake()); + +beforeEach(function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create([ + 'id' => 0, + 'is_dns_validation_enabled' => false, + ])); + + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\\Models\\StandaloneDocker', + 'fqdn' => 'https://app.example.com', + 'domain_dns_statuses' => [ + 'https://app.example.com' => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => null, + 'checked_at' => null, + 'check_id' => 'test-check', + ], + ], + ]); +}); + +it('persists a skipped result when dns validation is disabled', function () { + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + $status = $this->application->fresh()->domain_dns_statuses['https://app.example.com']; + + expect($status['status'])->toBe('skipped') + ->and($status['message'])->toBe('DNS validation is disabled in instance settings.') + ->and($status['checked_at'])->not->toBeNull(); +}); + +it('does not restore a dns status removed before the job finishes', function () { + $this->application->update(['domain_dns_statuses' => null]); + + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + expect($this->application->fresh()->domain_dns_statuses)->toBeNull(); +}); + +it('uses the shared dns action', function () { + CheckDomainDns::shouldRun() + ->once() + ->andReturn([ + 'https://app.example.com' => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => null, + 'checked_at' => now()->toIso8601String(), + ], + ]); + + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + expect($this->application->fresh()->domain_dns_statuses['https://app.example.com']['status'])->toBe('ok'); +}); + +it('does not let an older job overwrite a newer check for the same domain', function () { + $oldJob = new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ); + + $statuses = $this->application->domain_dns_statuses; + $statuses['https://app.example.com']['check_id'] = 'newer-check'; + $this->application->update(['domain_dns_statuses' => $statuses]); + + $oldJob->handle(); + + $status = $this->application->fresh()->domain_dns_statuses['https://app.example.com']; + + expect($status['status'])->toBe('checking') + ->and($status['check_id'])->toBe('newer-check'); +}); diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php index aed8eb85c..a9ac104c7 100644 --- a/tests/Feature/DnsValidationTest.php +++ b/tests/Feature/DnsValidationTest.php @@ -1,5 +1,6 @@ InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['is_dns_validation_enabled' => false] + )); + + $result = CheckDomainDns::run( + ['https://example.com' => 'https://example.com'], + new Server(['ip' => '203.0.113.10']), + '203.0.113.10', + ); + + expect($result['https://example.com']) + ->toMatchArray([ + 'status' => 'skipped', + 'message' => 'DNS validation is disabled in instance settings.', + 'expected_ip' => '203.0.113.10', + ]) + ->and($result['https://example.com']['checked_at'])->not->toBeNull(); +}); + it('stops querying DNS servers after finding a matching IP', function (string $resolvedIp) { InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( ['id' => 0], @@ -51,7 +73,40 @@ public function hasError(): bool expect(validateDNSEntry('https://example.com', $server))->toBeTrue() ->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1']); + + $result = CheckDomainDns::run(['example' => 'https://example.com'], $server, $targetIp); + + expect($result['example']['status'])->toBe('ok') + ->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1', '192.0.2.1']); })->with([ 'target server IP' => '203.0.113.10', 'Cloudflare IP' => '104.16.0.1', ]); + +it('does not start another resolver query after the total dns budget is exhausted', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + [ + 'is_dns_validation_enabled' => true, + 'custom_dns_servers' => '192.0.2.1,192.0.2.2', + ] + )); + + $queryCount = new ArrayObject; + app()->bind(DNSQuery::class, function () use ($queryCount) { + $queryCount->append(true); + + return new DNSQuery('192.0.2.1'); + }); + + $result = CheckDomainDns::run( + ['example' => 'https://example.com'], + new Server(['ip' => '203.0.113.10']), + '203.0.113.10', + timeoutSeconds: 0, + ); + + expect($result['example']['status'])->toBe('failed') + ->and($result['example']['message'])->toBe('Could not validate DNS for this domain.') + ->and($queryCount)->toHaveCount(0); +}); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 899aa521f..76ca4f2a8 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -1,5 +1,6 @@ webApp->update(['redirect' => 'both']); - Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $this->webApp->id) ->set('newDomain', 'https://web.example.com') ->call('addDomain') ->assertHasNoErrors() - ->assertDispatched('success') + ->assertDispatched('success'); + + $component->call('pollDnsChecks') ->assertSee('DNS skipped'); expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com'); }); it('adds a domain to a selected service application', function () { + Queue::fake(); + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $this->webApp->id) ->set('newDomain', 'https://web.example.com') ->call('addDomain') ->assertHasNoErrors() - ->assertDispatched('success') + ->assertDispatched('success', 'Domain added. DNS check started.') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', 'https://web.example.com')['dns_status'] === 'checking') ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://web.example.com')) ->assertSee('https://web.example.com'); $this->webApp->refresh(); expect($this->webApp->fqdn)->toBe('https://web.example.com'); - $dnsStatuses = $this->webApp->domain_dns_statuses; + expect($this->webApp->domain_dns_statuses['https://web.example.com']['status'] ?? null)->toBe('checking'); - expect($dnsStatuses) - ->toHaveKey('https://web.example.com') - ->not->toHaveKey('https://www.web.example.com') - ->and($dnsStatuses['https://web.example.com']['status']) - ->toBe('skipped') - ->and($dnsStatuses['https://web.example.com']['checked_at']) - ->not->toBeNull(); + Queue::assertPushed(CheckDomainDnsJob::class, 1); }); it('adds a domain when the compose service has an empty environment section', function () { @@ -457,7 +458,7 @@ ], ]); - Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->call('removeDomain', 0) ->set('newServiceApplicationId', $this->apiApp->id) ->set('newDomain', 'https://api.example.com') @@ -465,6 +466,8 @@ ->assertHasNoErrors() ->assertDispatched('success'); + $component->call('pollDnsChecks'); + $this->apiApp->refresh(); expect(explode(',', (string) $this->apiApp->fqdn)) @@ -564,6 +567,39 @@ ->assertDontSee('DNS points to 203.0.113.10'); }); +it('polls a queued service dns check and notifies about success', function () { + $domain = 'https://api.example.com'; + $this->apiApp->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->assertSee('Checking DNS...') + ->assertSee('wire:poll.2000ms="pollDnsChecks"', false); + + $this->apiApp->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->toIso8601String(), + ], + ], + ]); + + $component->call('pollDnsChecks') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', $domain)['dns_status'] === 'ok') + ->assertDispatched('success', 'DNS is configured correctly for api.example.com.'); +}); + it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index b4f78d9e0..aa7da543b 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -6,6 +6,7 @@ use App\Jobs\VolumeBackupRecoveryJob; use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBackup; use App\Livewire\Project\Service\FileStorage; +use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\Show; use App\Livewire\Project\Shared\Storages\VolumeBackups; use App\Models\Application; @@ -20,6 +21,7 @@ use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\Service; +use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\Team; @@ -187,6 +189,45 @@ ->and($backup->s3_storage_id)->toBeNull(); }); +it('shows readable service storage backup target labels', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application] = createVolumeBackupApplication($team); + $service = Service::factory()->create([ + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); + $resource = ServiceApplication::create([ + 'uuid' => new_public_id(), + 'name' => 'directus', + 'service_id' => $service->id, + ]); + LocalPersistentVolume::create([ + 'name' => $service->uuid.'_directus-templates', + 'mount_path' => '/directus/templates', + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]); + LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([ + 'uuid' => new_public_id(), + 'fs_path' => './uploads', + 'mount_path' => '/directus/uploads', + 'is_directory' => true, + 'is_based_on_git' => false, + 'is_preview_suffix_enabled' => true, + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]))); + + Livewire::test(CreateServiceVolumeBackup::class, ['service' => $service]) + ->assertSet('targets.0.name', 'directus-templates') + ->assertSet('targets.0.type', 'Directus') + ->assertSet('targets.1.name', './uploads (directory)') + ->assertSet('targets.1.type', 'Directus') + ->assertSee('Directus: directus-templates'); +}); + it('handles scheduled backup persistence failures', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team);