diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php index 1a7ceaeabc..c013da25a6 100644 --- a/app/Jobs/CheckDomainDnsJob.php +++ b/app/Jobs/CheckDomainDnsJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Actions\Shared\CheckDomainDns; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceApplication; use Illuminate\Bus\Queueable; @@ -23,7 +24,7 @@ class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue public int $timeout = 30; public function __construct( - public Application|ServiceApplication $resource, + public Application|ApplicationPreview|ServiceApplication $resource, public string $statusKey, public string $url, public ?Server $server, diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 4168a208cc..d736ec3859 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -1007,6 +1007,7 @@ class Domains extends Component $skipDns = ! $this->dnsValidationEnabled || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php new file mode 100644 index 0000000000..f2557e4f56 --- /dev/null +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -0,0 +1,440 @@ + 'https', 'host' => '', 'port' => '', 'path' => '']; + + public ?string $newDomainService = null; + + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public ?int $editingIndex = null; + + public function mount(): void + { + $this->refreshDomains(); + if ($this->preview->application->build_pack === 'dockercompose') { + $this->newDomainService = $this->composeServices()[0] ?? null; + } + } + + public function render() + { + return view('livewire.project.application.preview-domains', [ + 'isCompose' => $this->preview->application->build_pack === 'dockercompose', + 'composeServices' => $this->composeServices(), + ]); + } + + public function addDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->preview->application->build_pack === 'dockercompose' + && ($this->newDomainService === null || ! in_array($this->newDomainService, $this->composeServices(), true))) { + $this->addError('newDomainService', 'Select a valid Compose service.'); + + return; + } + $domain = $this->validatedDomain($this->newDomainParts, 'newDomainParts.host'); + if ($domain === null) { + return; + } + if (collect($this->domainRows)->contains(fn (array $row): bool => $row['url'] === $domain && $row['service'] === $this->newDomainService)) { + $this->addError('newDomainParts.host', 'This domain is already configured.'); + + return; + } + $this->domainRows[] = $this->makeRow($domain, $this->newDomainService); + $index = array_key_last($this->domainRows); + $checkId = new_public_id(); + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + if (! $this->persistDomains()) { + return; + } + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainService = $this->preview->application->build_pack === 'dockercompose' + ? ($this->composeServices()[0] ?? null) + : null; + $this->dispatch('close-modal'); + + try { + $server = $this->preview->application->destination?->server; + CheckDomainDnsJob::dispatch( + $this->preview, + $this->statusKey($domain, $this->domainRows[$index]['service']), + $domain, + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $checkId, + $this->preview->application->additional_servers->count() > 0, + ); + $this->dispatch('success', 'Domain added. DNS check started.'); + } catch (\Throwable) { + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['check_id'] = null; + $this->persistDnsStatuses(); + $this->dispatch('error', 'Domain added, but the DNS check could not be started. Try again from the preview domains list.'); + } + } + + public function generateDomain(): void + { + $this->authorize('update', $this->preview->application); + $this->preview->refresh(); + if ($this->preview->application->build_pack === 'dockercompose') { + if ($this->newDomainService === null && $this->domainRows === []) { + $this->preview->generate_preview_fqdn_compose(generateWithoutApplicationDomain: true); + } else { + $service = $this->newDomainService ?? data_get($this->domainRows, '0.service'); + foreach ($this->generateComposeDomains((string) $service) as $domain) { + $alreadyExists = collect($this->domainRows)->contains( + fn (array $row): bool => $row['url'] === $domain && $row['service'] === $service + ); + if (! $alreadyExists) { + $this->domainRows[] = $this->makeRow($domain, $service); + } + } + + if (! $this->persistDomains()) { + return; + } + } + } else { + $this->preview->generate_preview_fqdn(generateWithoutApplicationDomain: true); + } + $this->refreshDomains(); + $this->dispatch('success', 'Domain generated.'); + } + + public function startEdit(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + $this->editingIndex = $index; + $this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']); + $this->dispatch('open-preview-domain-edit'); + } + + public function updateDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { + return; + } + $domain = $this->validatedDomain($this->editingDomainParts, 'editingDomainParts.host'); + if ($domain === null) { + return; + } + $this->domainRows[$this->editingIndex]['url'] = $domain; + $this->domainRows[$this->editingIndex]['dns_status'] = 'pending'; + $this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.'; + $index = $this->editingIndex; + $this->editingIndex = null; + if (! $this->persistDomains()) { + return; + } + $this->dispatch('close-preview-domain-edit'); + $this->dispatch('success', 'Domain updated.'); + $this->checkDomainDns($index); + } + + public function removeDomain(int $index): void + { + $this->authorize('update', $this->preview->application); + if (! isset($this->domainRows[$index])) { + return; + } + unset($this->domainRows[$index]); + $this->domainRows = array_values($this->domainRows); + if (! $this->persistDomains()) { + return; + } + $this->dispatch('success', 'Domain removed.'); + } + + public function removeDomainByKey(string $domainKey): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => hash_equals($domainKey, $this->statusKey($row['url'], $row['service'])) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index); + } + + public function checkAllDns(): void + { + $this->authorize('update', $this->preview->application); + foreach (array_keys($this->domainRows) as $index) { + $this->applyDnsCheck($index); + } + $this->persistDnsStatuses(); + } + + public function checkDomainDns(int $index): void + { + $this->authorize('update', $this->preview->application); + $this->applyDnsCheck($index); + $this->persistDnsStatuses(); + } + + public function pollDnsChecks(): void + { + $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']); + } + } + + private 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}."), + }; + } + + private function applyDnsCheck(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + $result = $this->checkUrlDns($this->domainRows[$index]['url'], (string) $index); + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + } + + private function checkUrlDns(string $url, string $key = 'domain'): array + { + $server = $this->preview->application->destination?->server; + + return CheckDomainDns::run( + [$key => $url], + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $this->preview->application->additional_servers->count() > 0, + )[$key]; + } + + private function refreshDomains(): void + { + $this->preview->refresh(); + $statuses = $this->preview->domain_dns_statuses ?? []; + $rows = []; + if ($this->preview->application->build_pack === 'dockercompose') { + foreach (json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [] as $service => $entry) { + foreach ($this->splitDomains(composeDomainEntryString($entry)) as $url) { + $rows[] = $this->makeRow($url, (string) $service, $statuses); + } + } + } else { + foreach ($this->splitDomains($this->preview->fqdn) as $url) { + $rows[] = $this->makeRow($url, null, $statuses); + } + } + $this->domainRows = $rows; + } + + private function persistDomains(): bool + { + if ($this->preview->application->build_pack === 'dockercompose') { + try { + $composeServices = $this->composeServices(failOnError: true); + } catch (\Throwable) { + $this->refreshDomains(); + $this->dispatch('error', 'Compose configuration could not be parsed. Preview domains were not changed.'); + + return false; + } + $domains = collect($composeServices) + ->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']]) + ->all(); + $validRows = collect($this->domainRows) + ->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true)); + foreach ($validRows->groupBy('service') as $service => $rows) { + $domains[$service] = ['domain' => $rows->pluck('url')->implode(',')]; + } + $this->preview->docker_compose_domains = json_encode($domains); + $this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null; + } else { + $this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null; + } + $this->preview->save(); + $this->persistDnsStatuses(); + $this->dispatch('update_links'); + $this->dispatch('previewDomainsChanged'); + + return true; + } + + private function persistDnsStatuses(): void + { + $statuses = []; + foreach ($this->domainRows as $row) { + $statuses[$this->statusKey($row['url'], $row['service'])] = [ + 'status' => $row['dns_status'], + 'message' => $row['dns_message'], + 'check_id' => $row['check_id'] ?? null, + ]; + } + + DB::transaction(function () use (&$statuses): 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)) { + continue; + } + + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatus['check_id'] ?? null; + + if (($storedCheckId !== null && $localCheckId !== $storedCheckId) + || ($status['status'] === 'checking' && ($storedStatus['status'] ?? null) !== 'checking')) { + $statuses[$key] = $storedStatus; + } + } + + $preview->domain_dns_statuses = $statuses ?: null; + $preview->save(); + }); + + $this->preview->domain_dns_statuses = $statuses ?: null; + } + + private function validatedDomain(array $parts, string $errorKey): ?string + { + $domain = DomainUrlParts::compose(...$parts); + $validator = validator(['domain' => $domain], ['domain' => ValidationPatterns::applicationDomainRules()]); + if ($validator->fails()) { + $this->addError($errorKey, $validator->errors()->first('domain')); + + return null; + } + + return ValidationPatterns::normalizeApplicationDomains($domain); + } + + private function makeRow(string $url, ?string $service, array $statuses = []): array + { + $status = $statuses[$this->statusKey($url, $service)] ?? []; + + return [ + 'url' => $url, + 'service' => $service, + 'dns_status' => $status['status'] ?? 'pending', + 'dns_message' => $status['message'] ?? 'DNS has not been checked yet.', + 'check_id' => $status['check_id'] ?? null, + ]; + } + + private function statusKey(string $url, ?string $service): string + { + return hash('sha256', $url.'|'.($service ?? '')); + } + + private function splitDomains(?string $domains): array + { + return str($domains)->explode(',')->map(fn ($domain) => trim((string) $domain))->filter()->values()->all(); + } + + private function generateComposeDomains(string $service): array + { + $applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []; + $domainString = getComposeServiceDomainString($applicationDomains, $service); + + if (empty($domainString)) { + $domainString = generateUrl( + server: $this->preview->application->destination->server, + random: str($service)->slug().'-'.$this->preview->application->uuid, + ); + } + + return collect($this->splitDomains($domainString))->map(function (string $domain): string { + $url = Url::fromString($domain); + $generatedDomain = str_replace('{{random}}', new_public_id(), $this->preview->application->preview_url_template); + $generatedDomain = str_replace('{{domain}}', $url->getHost(), $generatedDomain); + $generatedDomain = str_replace('{{pr_id}}', (string) $this->preview->pull_request_id, $generatedDomain); + $port = $url->getPort() !== null ? ':'.$url->getPort() : ''; + $path = ! in_array($url->getPath(), ['', '/'], true) ? $url->getPath() : ''; + + return "{$url->getScheme()}://{$generatedDomain}{$port}{$path}"; + })->all(); + } + + private function composeServices(bool $failOnError = false): array + { + try { + $parsedCompose = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id); + $services = data_get($parsedCompose, 'services', []); + if (! is_iterable($services)) { + return []; + } + + $usesLegacyServiceKeys = (int) $this->preview->application->compose_parsing_version < 3; + $previewSuffix = '-pr-'.$this->preview->pull_request_id; + $serviceNames = []; + foreach ($services as $serviceName => $service) { + if (isDatabaseImage(data_get($service, 'image'))) { + continue; + } + + $serviceName = (string) $serviceName; + if ($usesLegacyServiceKeys && str_ends_with($serviceName, $previewSuffix)) { + $serviceName = substr($serviceName, 0, -strlen($previewSuffix)); + } + $serviceNames[] = $serviceName; + } + + return array_values(array_unique($serviceNames)); + } catch (\Throwable $exception) { + if ($failOnError) { + throw $exception; + } + + return []; + } + } +} diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index e07a985b40..fa0272bd64 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -7,7 +7,6 @@ use App\Events\ServiceStatusChanged; use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationPreview; -use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; @@ -16,6 +15,8 @@ class Previews extends Component { use AuthorizesRequests; + protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains']; + public Application $application; public string $deployment_uuid; @@ -26,16 +27,6 @@ class Previews extends Component public int $rate_limit_remaining; - public $domainConflicts = []; - - public $showDomainConflictModal = false; - - public $forceSaveDomains = false; - - public $pendingPreviewId = null; - - public array $previewFqdns = []; - public array $previewDockerTags = []; public ?int $manualPullRequestId = null; @@ -43,7 +34,6 @@ class Previews extends Component public ?string $manualDockerTag = null; protected $rules = [ - 'previewFqdns.*' => 'string|nullable', 'previewDockerTags.*' => 'string|nullable', 'manualPullRequestId' => 'integer|min:1|nullable', 'manualDockerTag' => 'string|nullable', @@ -53,31 +43,23 @@ class Previews extends Component { $this->pull_requests = collect(); $this->parameters = get_route_parameters(); - $this->syncData(false); + $this->syncDockerTags(); } - private function syncData(bool $toModel = false): void + private function syncDockerTags(): void { - if ($toModel) { - foreach ($this->previewFqdns as $key => $fqdn) { - $preview = $this->application->previews->get($key); - if ($preview) { - $preview->fqdn = $fqdn; - if ($this->application->build_pack === 'dockerimage') { - $preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null; - } - } - } - } else { - $this->previewFqdns = []; - $this->previewDockerTags = []; - foreach ($this->application->previews as $key => $preview) { - $this->previewFqdns[$key] = $preview->fqdn; - $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; - } + $this->previewDockerTags = []; + foreach ($this->application->previews as $key => $preview) { + $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; } } + public function refreshPreviewDomains(): void + { + $this->application->refresh(); + $this->syncDockerTags(); + } + public function load_prs() { try { @@ -92,103 +74,28 @@ class Previews extends Component } } - public function confirmDomainUsage() - { - $this->forceSaveDomains = true; - $this->showDomainConflictModal = false; - if ($this->pendingPreviewId) { - $this->save_preview($this->pendingPreviewId); - $this->pendingPreviewId = null; - } - } - public function save_preview($preview_id) { try { $this->authorize('update', $this->application); - $success = true; $preview = $this->application->previews->find($preview_id); if (! $preview) { throw new \Exception('Preview not found'); } - // Find the key for this preview in the collection $previewKey = $this->application->previews->search(function ($item) use ($preview_id) { return $item->id == $preview_id; }); - if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { - $this->validate([ - "previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(), - ]); - - $fqdn = $this->previewFqdns[$previewKey]; - - if (! empty($fqdn)) { - $fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn); - $this->previewFqdns[$previewKey] = $fqdn; - - if (! validateDNSEntry($fqdn, $this->application->destination->server)) { - $server = $this->application->destination->server; - $target = serverDnsTargetIp($server) ?? $server->ip; - $guidance = dnsMismatchGuidanceMessage($target, $target); - $this->dispatch('error', 'Validating DNS failed.', "{$guidance}

Check this documentation for further help."); - $success = false; - } - - // Check for domain conflicts if not forcing save - if (! $this->forceSaveDomains) { - $result = checkDomainUsage(resource: $this->application, domain: $fqdn); - if ($result['hasConflicts']) { - $this->domainConflicts = $result['conflicts']; - $this->showDomainConflictModal = true; - $this->pendingPreviewId = $preview_id; - - return; - } - } else { - // Reset the force flag after using it - $this->forceSaveDomains = false; - } - } + if ($previewKey === false) { + throw new \Exception('Preview not found'); } - if ($success) { - $this->syncData(true); - $preview->save(); - $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); - } - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function generate_preview($preview_id) - { - try { - $this->authorize('update', $this->application); - - $preview = $this->application->previews->find($preview_id); - if (! $preview) { - $this->dispatch('error', 'Preview not found.'); - - return; - } - if ($this->application->build_pack === 'dockercompose') { - $preview->generate_preview_fqdn_compose(); - $this->application->refresh(); - $this->syncData(false); - $this->dispatch('success', 'Domain generated.'); - - return; - } - - $preview->generate_preview_fqdn(); - $this->application->refresh(); - $this->syncData(false); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); + $this->validateOnly("previewDockerTags.{$previewKey}"); + $preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null; + $preview->save(); + $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -211,7 +118,7 @@ class Previews extends Component } $found->generate_preview_fqdn_compose(); $this->application->refresh(); - $this->syncData(false); + $this->syncDockerTags(); } else { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -227,9 +134,9 @@ class Previews extends Component $found->docker_registry_image_tag = $docker_registry_image_tag; $found->save(); } - $found->generate_preview_fqdn(); + $found->generate_preview_fqdn(generateWithoutApplicationDomain: true); $this->application->refresh(); - $this->syncData(false); + $this->syncDockerTags(); $this->dispatch('update_links'); $this->dispatch('success', 'Preview added.'); } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php deleted file mode 100644 index 0fdcf46153..0000000000 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ /dev/null @@ -1,165 +0,0 @@ -domain = data_get($this->service, 'domain'); - } - - public function render() - { - return view('livewire.project.application.previews-compose'); - } - - public function save() - { - try { - $this->authorize('update', $this->preview->application); - $this->validate([ - 'domain' => ValidationPatterns::applicationDomainRules(), - ]); - - $this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain); - $this->persistPreviewDomain($this->domain); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain saved.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function generate() - { - try { - $this->authorize('update', $this->preview->application); - - $applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []; - $domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName); - - // If no domain is set in the main application, generate a default domain - if (empty($domain_string)) { - $server = $this->preview->application->destination->server; - $template = $this->preview->application->preview_url_template; - $random = new_public_id(); - - // Generate a unique domain like main app services do - $generated_fqdn = generateUrl(server: $server, random: $random); - - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; - } else { - foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) { - throw new \InvalidArgumentException($error); - } - - // Use the existing domain from the main application - // Handle multiple domains separated by commas - $domain_list = ValidationPatterns::applicationDomainList($domain_string); - $preview_fqdns = []; - $template = $this->preview->application->preview_url_template; - $random = new_public_id(); - - foreach ($domain_list as $single_domain) { - $single_domain = trim($single_domain); - if (empty($single_domain)) { - continue; - } - - $url = Url::fromString($single_domain); - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdns[] = "$schema://$preview_fqdn{$port}"; - } - - $preview_fqdn = implode(',', $preview_fqdns); - } - - $this->domain = $preview_fqdn; - $this->persistPreviewDomain($this->domain); - - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - private function persistPreviewDomain(?string $domain): void - { - $docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []; - $serviceNames = $this->previewServiceNames($docker_compose_domains); - $storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames) - ?? (string) $this->serviceName; - - $docker_compose_domains = putComposeServiceDomain( - $docker_compose_domains, - $storageKey, - $domain, - $serviceNames, - ); - $docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames); - - $this->serviceName = $storageKey; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); - } - - /** - * @param array $previewDomains - * @return list - */ - private function previewServiceNames(array $previewDomains): array - { - $parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id); - $fromCompose = collect(data_get($parsedServices, 'services', [])) - ->keys() - ->map(function ($serviceName) { - return str((string) $serviceName) - ->replaceLast('-pr-'.$this->preview->pull_request_id, '') - ->toString(); - }) - ->all(); - - $domainKeys = collect(array_keys($previewDomains)) - ->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [])) - ->map(fn ($name) => (string) $name); - $unmapped = $domainKeys - ->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null) - ->all(); - - return collect($fromCompose) - ->merge(preferredComposeServiceNamesFromDomainKeys( - $fromCompose === [] ? $domainKeys->all() : $unmapped - )) - ->unique() - ->values() - ->all(); - } -} diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 7708e55352..b5ca45b873 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -1447,6 +1447,7 @@ class Domains extends Component $urlSet = array_fill_keys($urls, true); $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 0905242753..d9c981c591 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -23,10 +23,12 @@ class ApplicationPreview extends BaseModel 'docker_compose_domains', 'docker_registry_image_tag', 'last_online_at', + 'domain_dns_statuses', ]; protected $casts = [ 'pull_request_id' => 'integer', + 'domain_dns_statuses' => 'array', ]; protected static function booted(): void @@ -105,13 +107,21 @@ class ApplicationPreview extends BaseModel return $this->morphMany(LocalPersistentVolume::class, 'resource'); } - public function generate_preview_fqdn() + public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = false) { - if ($this->application->fqdn) { - if (str($this->application->fqdn)->contains(',')) { - $url = Url::fromString(str($this->application->fqdn)->explode(',')[0]); + $applicationFqdn = $this->application->fqdn; + if (! $applicationFqdn && $generateWithoutApplicationDomain) { + $applicationFqdn = generateUrl( + server: $this->application->destination->server, + random: $this->application->uuid, + ); + } + + if ($applicationFqdn) { + if (str($applicationFqdn)->contains(',')) { + $url = Url::fromString(str($applicationFqdn)->explode(',')[0]); } else { - $url = Url::fromString($this->application->fqdn); + $url = Url::fromString($applicationFqdn); } $template = $this->application->preview_url_template; $host = $url->getHost(); @@ -132,7 +142,7 @@ class ApplicationPreview extends BaseModel return $this; } - public function generate_preview_fqdn_compose() + public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDomain = false) { $applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []; $previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: []; @@ -174,8 +184,15 @@ class ApplicationPreview extends BaseModel foreach ($serviceNames as $service_name) { $domain_string = getComposeServiceDomainString($applicationDomains, $service_name); - // If domain string is empty or null, don't auto-generate domain - // Only generate domains when main app already has domains set + if (empty($domain_string)) { + if ($generateWithoutApplicationDomain) { + $domain_string = generateUrl( + server: $this->application->destination->server, + random: str($service_name)->slug().'-'.$this->application->uuid, + ); + } + } + if (empty($domain_string)) { $docker_compose_domains = putComposeServiceDomain( $docker_compose_domains, diff --git a/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php b/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php new file mode 100644 index 0000000000..fd1865ed32 --- /dev/null +++ b/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php @@ -0,0 +1,28 @@ +json('domain_dns_statuses')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('domain_dns_statuses'); + }); + } +}; diff --git a/resources/views/livewire/project/application/preview-domains.blade.php b/resources/views/livewire/project/application/preview-domains.blade.php new file mode 100644 index 0000000000..766d0f64a3 --- /dev/null +++ b/resources/views/livewire/project/application/preview-domains.blade.php @@ -0,0 +1,152 @@ +
+ @if (collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking')) + + @endif +
+

+ {{ count($domainRows) }} domain{{ count($domainRows) === 1 ? '' : 's' }} +

+ @can('update', $preview->application) + @if (count($domainRows) > 0) + + + Recheck DNS + + @endif + + + + +
+ @if ($isCompose && count($composeServices) > 0) + + @endif + + +
+ Generate domain + Save +
+ +
+ @endcan +
+ + @if (count($domainRows) === 0) +
+ +
+ @else +
+
+ Domain + DNS check + + +
+ @foreach ($domainRows as $index => $row) + @php + $dnsType = match ($row['dns_status']) { + 'ok' => 'success', + 'failed' => 'error', + 'skipped' => 'warning', + default => 'neutral', + }; + $dnsLabel = match ($row['dns_status']) { + 'ok' => 'DNS OK', + 'failed' => 'DNS mismatch', + 'skipped' => 'DNS skipped', + default => 'DNS pending', + }; + $domainKey = hash('sha256', $row['url'].'|'.($row['service'] ?? '')); + @endphp +
+
+
+
+ + {{ $row['url'] }} + @if (filled($row['service'])) + {{ $row['service'] }} + @endif +
+
+
+ +
+ +
+ @can('update', $preview->application) + + + + + + + + @endcan +
+
+
+ @endforeach +
+ @endif + + +
diff --git a/resources/views/livewire/project/application/previews-compose.blade.php b/resources/views/livewire/project/application/previews-compose.blade.php deleted file mode 100644 index 85159682d8..0000000000 --- a/resources/views/livewire/project/application/previews-compose.blade.php +++ /dev/null @@ -1,6 +0,0 @@ -
- - Generate domain - diff --git a/resources/views/livewire/project/application/previews.blade.php b/resources/views/livewire/project/application/previews.blade.php index ef42760b97..13b73ef66b 100644 --- a/resources/views/livewire/project/application/previews.blade.php +++ b/resources/views/livewire/project/application/previews.blade.php @@ -251,49 +251,19 @@
- @if ($application->build_pack === 'dockercompose') - @if (collect(json_decode($preview->docker_compose_domains))->count() === 0) -
- - @can('update', $application) - - Generate domain - - @endcan - - @else -
- @foreach (collect(json_decode($preview->docker_compose_domains)) as $serviceName => $service) - - @endforeach -
- @endif - @else + + + @if ($application->build_pack === 'dockerimage')
- - @if ($application->build_pack === 'dockerimage') - +
Docker tag
+
+ - @endif - @can('update', $application) - - Generate domain - - @endcan +
@endif
@@ -305,15 +275,4 @@ @endforelse - - The preview deployment domain is already used by another resource and may cause routing conflicts. - -
    -
  • The preview deployment may not be accessible.
  • -
  • SSL certificates may not work correctly.
  • -
  • Requests may be routed unpredictably.
  • -
-
-
diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 43d1535596..8e6775134b 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2,7 +2,10 @@ use App\Jobs\CheckDomainDnsJob; use App\Livewire\Project\Application\Domains; +use App\Livewire\Project\Application\PreviewDomains; +use App\Livewire\Project\Application\Previews; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\Project; @@ -107,6 +110,469 @@ it('does not add a single-label hostname as an application domain', function () expect($this->application->fresh()->fqdn)->toBeNull(); }); +it('generates a preview domain when the application has no domain', function () { + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 41, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/41', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('generateDomain') + ->assertDispatched('success'); + + expect($preview->fresh()->fqdn) + ->not->toBeNull() + ->toContain('41.'); +}); + +it('generates compose preview domains when the application has no domains', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + 'docker_compose_domains' => null, + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/42', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('generateDomain') + ->assertDispatched('success'); + + expect($preview->fresh()->fqdn) + ->not->toBeNull() + ->toContain('42.'); +}); + +it('derives preview domain services from compose and defaults the selected service', function () { + Queue::fake(); + + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n worker-pr-49:\n image: nginx:alpine\n database:\n image: postgres:17\n", + 'docker_compose_domains' => null, + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 49, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/49', + 'docker_compose_domains' => json_encode([ + 'removed-service' => ['domain' => ''], + ]), + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertViewHas('composeServices', ['web', 'worker-pr-49']) + ->assertSet('newDomainService', 'web') + ->set('newDomainService', 'worker-pr-49') + ->set('newDomainParts.host', 'worker-preview.example.com') + ->call('addDomain') + ->assertHasNoErrors() + ->assertSet('newDomainService', 'web'); + + $preview->generate_preview_fqdn_compose(); + + expect(json_decode($preview->fresh()->docker_compose_domains, true)) + ->toHaveKey('worker-pr-49') + ->not->toHaveKey('worker'); +}); + +it('handles compose parsing failures without exposing stale preview services', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services: [\n", + 'docker_compose_domains' => null, + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 52, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/52', + 'docker_compose_domains' => json_encode([ + 'stale-service' => ['domain' => ''], + ]), + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertViewHas('composeServices', []) + ->assertSet('newDomainService', null); +}); + +it('does not erase preview domains when compose parsing fails during persistence', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n worker:\n image: nginx:alpine\n", + ]); + + $storedDomains = [ + 'web' => ['domain' => 'https://web-preview.example.com'], + 'worker' => ['domain' => 'https://worker-preview.example.com'], + ]; + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 53, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/53', + 'docker_compose_domains' => json_encode($storedDomains), + 'fqdn' => 'https://web-preview.example.com,https://worker-preview.example.com', + ]); + + $component = Livewire::test(PreviewDomains::class, ['preview' => $preview]); + + $application = Mockery::mock($component->instance()->preview->application)->makePartial(); + $application->shouldReceive('parse')->once()->andThrow(new RuntimeException('Temporary parse failure')); + $component->instance()->preview->setRelation('application', $application); + + $component->instance()->removeDomain(0); + + expect(json_decode($preview->fresh()->docker_compose_domains, true))->toBe($storedDomains) + ->and($preview->fresh()->fqdn)->toBe('https://web-preview.example.com,https://worker-preview.example.com') + ->and($component->get('domainRows'))->toHaveCount(2); +}); + +it('preserves empty compose service slots after removing the last preview domain', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n worker:\n image: nginx:alpine\n", + 'docker_compose_domains' => null, + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 50, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/50', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://web-preview.example.com'], + ]), + 'fqdn' => 'https://web-preview.example.com', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('removeDomain', 0) + ->assertViewHas('composeServices', ['web', 'worker']); + + expect(json_decode($preview->fresh()->docker_compose_domains, true))->toBe([ + 'web' => ['domain' => ''], + 'worker' => ['domain' => ''], + ]); +}); + +it('rejects missing and unknown services when adding compose preview domains', function (?string $service) { + Queue::fake(); + + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + 'docker_compose_domains' => null, + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 51, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/51', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->set('newDomainParts.host', 'preview.example.com') + ->set('newDomainService', $service) + ->call('addDomain') + ->assertHasErrors('newDomainService') + ->assertCount('domainRows', 0); + + expect($preview->fresh()->docker_compose_domains)->toBeNull() + ->and($preview->fresh()->fqdn)->toBeNull(); + Queue::assertNothingPushed(); +})->with([null, 'unknown']); + +it('generates a compose preview domain only for the selected service', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://web.example.com'], + 'api' => ['domain' => 'https://api.example.com'], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 48, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/48', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://custom-web.example.net'], + 'api' => ['domain' => 'https://custom-api.example.net'], + ]), + 'fqdn' => 'https://custom-web.example.net,https://custom-api.example.net', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->set('newDomainService', 'api') + ->call('generateDomain') + ->assertDispatched('success'); + + $domains = json_decode($preview->fresh()->docker_compose_domains, true); + + expect($domains['web']['domain'])->toBe('https://custom-web.example.net') + ->and($domains['api']['domain'])->toStartWith('https://custom-api.example.net,') + ->and($domains['api']['domain'])->toContain('48.api.example.com'); +}); + +it('keeps compose services without application domains private when configuring a preview', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n worker:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://web.example.com'], + 'worker' => ['domain' => ''], + ]), + ]); + + Livewire::test(Previews::class, ['application' => $this->application->fresh()]) + ->set('parameters', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ]) + ->call('add', 43, 'https://github.com/coollabsio/coolify/pull/43'); + + $preview = ApplicationPreview::query() + ->where('application_id', $this->application->id) + ->where('pull_request_id', 43) + ->firstOrFail(); + $composeDomains = json_decode($preview->docker_compose_domains, true); + + expect($composeDomains['web']['domain']) + ->toContain('web.example.com') + ->and($composeDomains['worker']['domain'])->toBe('') + ->and($preview->fqdn)->toContain('web.example.com') + ->not->toContain('worker'); +}); + +it('generates a domain when configuring a preview', function () { + Livewire::test(Previews::class, ['application' => $this->application->fresh()]) + ->set('parameters', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ]) + ->call('add', 43, 'https://github.com/coollabsio/coolify/pull/43') + ->assertDispatched('success'); + + $preview = ApplicationPreview::query() + ->where('application_id', $this->application->id) + ->where('pull_request_id', 43) + ->firstOrFail(); + + expect($preview->fqdn) + ->not->toBeNull() + ->toContain('43.'); +}); + +it('manages preview domains and their dns status', function () { + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 44, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/44', + 'fqdn' => 'https://44.example.com', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertSet('domainRows.0.url', 'https://44.example.com') + ->call('checkDomainDns', 0) + ->assertSet('domainRows.0.dns_status', 'skipped') + ->set('newDomainParts.host', 'second.example.com') + ->call('addDomain') + ->assertHasNoErrors() + ->assertDispatched('success') + ->assertCount('domainRows', 2) + ->call('removeDomain', 0) + ->assertCount('domainRows', 1); + + expect($preview->fresh()->fqdn)->toBe('https://second.example.com') + ->and($preview->fresh()->domain_dns_statuses)->not->toBeNull(); +}); + +it('removes the intended preview domains by stable identities after reindexing', function () { + $domains = [ + 'https://first.example.com', + 'https://second.example.com', + 'https://third.example.com', + ]; + $domainKeys = array_map(fn (string $domain): string => hash('sha256', $domain.'|'), $domains); + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 47, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/47', + 'fqdn' => implode(',', $domains), + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('removeDomainByKey', $domainKeys[0]) + ->assertSet('domainRows.0.url', $domains[1]) + ->call('removeDomainByKey', $domainKeys[1]) + ->assertCount('domainRows', 1) + ->assertSet('domainRows.0.url', $domains[2]); + + expect($preview->fresh()->fqdn)->toBe($domains[2]); +}); + +it('renders preview domain delete confirmations with stable keys', function () { + $domains = [ + 'https://first.example.com', + 'https://second.example.com', + ]; + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 48, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/48', + 'fqdn' => implode(',', $domains), + ]); + + $renderedHtml = html_entity_decode( + Livewire::test(PreviewDomains::class, ['preview' => $preview])->html(), + ENT_QUOTES, + ); + + foreach ($domains as $domain) { + $domainKey = hash('sha256', $domain.'|'); + + expect($renderedHtml)->toMatch("/submitAction:\\s*[\"']removeDomainByKey\\({$domainKey}\\)[\"']/"); + } + + expect($renderedHtml)->not->toMatch('/submitAction:\\s*[\"\']removeDomain\\(\\d+\\)[\"\']/'); +}); + +it('removes only the matching compose preview domain when services share a URL', function () { + $url = 'https://shared.example.com'; + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n worker:\n image: nginx:alpine\n", + ]); + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 49, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/49', + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => $url], + 'worker' => ['domain' => $url], + ]), + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('removeDomainByKey', hash('sha256', $url.'|worker')) + ->assertCount('domainRows', 1) + ->assertSet('domainRows.0.url', $url) + ->assertSet('domainRows.0.service', 'web'); + + expect(json_decode($preview->fresh()->docker_compose_domains, true))->toBe([ + 'web' => ['domain' => $url], + 'worker' => ['domain' => ''], + ]); +}); + +it('adds a preview domain and starts its dns check asynchronously', function () { + Queue::fake(); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 45, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/45', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->set('newDomainParts.host', 'preview.example.com') + ->call('addDomain') + ->assertCount('domainRows', 1) + ->assertSet('domainRows.0.dns_status', 'checking') + ->assertDispatched('success', 'Domain added. DNS check started.'); + + Queue::assertPushed(CheckDomainDnsJob::class, fn (CheckDomainDnsJob $job): bool => $job->resource->is($preview) + && $job->url === 'https://preview.example.com'); + + expect($preview->fresh()->fqdn)->toBe('https://preview.example.com') + ->and(collect($preview->fresh()->domain_dns_statuses)->first()['status'])->toBe('checking'); +}); + +it('notifies when an asynchronous preview dns check finds a mismatch', function () { + $url = 'https://preview.example.com'; + $statusKey = hash('sha256', $url.'|'); + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 46, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/46', + 'fqdn' => $url, + 'domain_dns_statuses' => [ + $statusKey => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'check_id' => 'preview-check', + ], + ], + ]); + + $component = Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertSet('domainRows.0.dns_status', 'checking'); + + $preview->update([ + 'domain_dns_statuses' => [ + $statusKey => [ + '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 preview.example.com. Review the required DNS record.'); +}); + +it('does not overwrite a completed preview dns result with stale checking state', function () { + $url = 'https://preview.example.com'; + $statusKey = hash('sha256', $url.'|'); + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 48, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/48', + 'fqdn' => $url, + 'domain_dns_statuses' => [ + $statusKey => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'check_id' => 'stale-check', + ], + ], + ]); + + $component = Livewire::test(PreviewDomains::class, ['preview' => $preview]); + + $preview->update([ + 'domain_dns_statuses' => [ + $statusKey => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'check_id' => 'completed-check', + ], + ], + ]); + + $method = new ReflectionMethod($component->instance(), 'persistDnsStatuses'); + $method->invoke($component->instance()); + + expect($preview->fresh()->domain_dns_statuses[$statusKey]) + ->toMatchArray([ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'check_id' => 'completed-check', + ]); +}); + it('lists existing domains as individual rows', function () { $this->application->update([ 'fqdn' => 'https://example.com,https://www.example.com,https://another.example.com,https://www.another.example.com', @@ -360,7 +826,8 @@ it('updates a domain in place via modal', function () { ->assertHasNoErrors() ->assertSet('showEditDomainModal', false) ->assertDispatched('edit-domain-saved') - ->assertDispatched('success'); + ->assertDispatched('success') + ->assertNotDispatched('error'); $this->application->refresh(); diff --git a/tests/Feature/CheckDomainDnsJobTest.php b/tests/Feature/CheckDomainDnsJobTest.php index 74502268a0..d760fdcc45 100644 --- a/tests/Feature/CheckDomainDnsJobTest.php +++ b/tests/Feature/CheckDomainDnsJobTest.php @@ -3,6 +3,7 @@ use App\Actions\Shared\CheckDomainDns; use App\Jobs\CheckDomainDnsJob; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\Project; @@ -57,6 +58,34 @@ it('persists a skipped result when dns validation is disabled', function () { ->and($status['checked_at'])->not->toBeNull(); }); +it('persists dns results for application previews', function () { + $statusKey = hash('sha256', 'https://preview.example.com|'); + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 41, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/41', + 'fqdn' => 'https://preview.example.com', + 'domain_dns_statuses' => [ + $statusKey => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'check_id' => 'preview-check', + ], + ], + ]); + + (new CheckDomainDnsJob( + $preview, + $statusKey, + 'https://preview.example.com', + null, + null, + 'preview-check', + ))->handle(); + + expect($preview->fresh()->domain_dns_statuses[$statusKey]['status'])->toBe('skipped'); +}); + it('does not restore a dns status removed before the job finishes', function () { $this->application->update(['domain_dns_statuses' => null]); diff --git a/tests/Feature/PreviewStatusSummaryTest.php b/tests/Feature/PreviewStatusSummaryTest.php index 6c17135618..dbc2b3a295 100644 --- a/tests/Feature/PreviewStatusSummaryTest.php +++ b/tests/Feature/PreviewStatusSummaryTest.php @@ -77,3 +77,22 @@ it('places links and logs dropdowns beside preview actions', function () { ->toContain('title="Preview logs"') ->toContain('title="Preview actions"'); }); + +it('uses the shared domain list treatment for preview domains', function () { + $view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php')); + $domainsView = file_get_contents(resource_path('views/livewire/project/application/preview-domains.blade.php')); + + expect($view) + ->toContain('and($domainsView) + ->toContain('Recheck DNS') + ->toContain('Add domain') + ->toContain('data-table-header') + ->toContain('domains-table-grid-service') + ->toContain('class="env-table-item"') + ->toContain('No domains configured') + ->toContain('class="data-table-row') + ->toContain('DNS OK') + ->toContain('Edit domain') + ->toContain('Remove domain'); +}); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 4c30ef6196..bbcc3a2c87 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -456,7 +456,8 @@ it('prunes the previous dns status when a service domain is renamed', function ( ->call('updateDomain') ->assertHasNoErrors() ->assertDispatched('edit-domain-saved') - ->assertDispatched('success'); + ->assertDispatched('success') + ->assertNotDispatched('error'); $this->apiApp->refresh();