diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 614175d9b4..595a21e799 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -45,15 +45,17 @@ jobs: exit 1 fi - existing_pr=$(gh pr list --base next --head main --state open --json url --jq '.[0].url') + sync_branch='automation/sync-main-to-next' + existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') if [ -n "$existing_pr" ]; then echo "A main to next pull request already exists: $existing_pr" else + git push --force origin origin/main:"refs/heads/$sync_branch" gh pr create \ --base next \ - --head main \ + --head "$sync_branch" \ --title 'chore: merge main into next' \ - --body 'This pull request was created automatically because main could not be merged into next without conflicts.' + --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' fi echo 'main could not be merged into next without conflicts.' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73b048f4b6..626f6d7b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor ## Local Development To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) +### Testing the Coolify Helper Locally + +Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications. + +Run the complete workflow: + +```bash +./scripts/dev-helper test my-helper-test +``` + +This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications. + +You can also run each step separately: + +```bash +./scripts/dev-helper build my-helper-test +./scripts/dev-helper use my-helper-test +./scripts/dev-helper verify my-helper-test +./scripts/dev-helper deploy my-helper-test +``` + +Clear the helper override when finished: + +```bash +./scripts/dev-helper reset +``` + +The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name. + ### macOS Development with Lima Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php new file mode 100644 index 0000000000..d0cea0fb1c --- /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/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 601c364de2..285cf8e2f1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1604,7 +1604,12 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first(); + $githubApp = GithubApp::where('uuid', $githubAppUuid) + ->where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + }) + ->first(); if (! $githubApp) { return response()->json(['message' => 'Github App not found.'], 404); } diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php new file mode 100644 index 0000000000..1a7ceaeabc --- /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 45a76a4c33..2f9370871c 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\DomainUrlParts; 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 @@ class Domains extends Component $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 @@ class Domains extends Component '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 @@ class Domains extends Component '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 @@ class Domains extends Component || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $reason = ! $this->dnsValidationEnabled @@ -548,7 +588,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -575,45 +619,50 @@ class Domains extends Component 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 @@ class Domains extends Component '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 @@ class Domains extends Component } } - 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 @@ class Domains extends Component $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 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -909,15 +1071,11 @@ class Domains extends Component 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 4690335d86..d932e76494 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 @@ class Domains extends Component $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 @@ class Domains extends Component '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 @@ class Domains extends Component '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 @@ class Domains extends Component $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 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -443,33 +486,37 @@ class Domains extends Component 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 @@ class Domains extends Component '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 @@ class Domains extends Component ->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 @@ class Domains extends Component } } - 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 @@ class Domains extends Component $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 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -1330,15 +1473,11 @@ class Domains extends Component 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 adb1234e69..430e89a2c1 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Create.php +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -99,8 +99,8 @@ class Create extends Component $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 @@ class Create extends Component ->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/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 40705617f1..829fbda800 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -212,7 +212,7 @@ class Index extends Component return; } - $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); + $imageRef = escapeshellarg(coolifyHelperImage().":{$version}"); $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $activity = remote_process( diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 6bb4c5e908..116db1eed1 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -47,7 +47,7 @@ class Index extends Component return [ 'name' => data_get($container, 'Names'), 'connection_name' => data_get($container, 'Names'), - 'uuid' => data_get($container, 'Names'), + 'uuid' => $server->uuid.':'.data_get($container, 'Names'), 'status' => data_get_str($container, 'State')->lower(), 'server' => $server, 'server_uuid' => $server->uuid, diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 6998211eea..0905242753 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\SoftDeletes; +use RuntimeException; use Spatie\Url\Url; class ApplicationPreview extends BaseModel @@ -28,9 +29,9 @@ class ApplicationPreview extends BaseModel 'pull_request_id' => 'integer', ]; - protected static function booted() + protected static function booted(): void { - static::forceDeleting(function ($preview) { + static::forceDeleting(function (ApplicationPreview $preview): void { $server = $preview->application->destination->server; $application = $preview->application; @@ -57,10 +58,19 @@ class ApplicationPreview extends BaseModel }); } else { // Regular application volume cleanup - $persistentStorages = $preview->persistentStorages()->get() ?? collect(); - if ($persistentStorages->count() > 0) { - foreach ($persistentStorages as $storage) { - instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); + $persistentStorages = $application->persistentStorages() + ->get() + ->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path) + && $storage->is_preview_suffix_enabled); + + foreach ($persistentStorages as $storage) { + $volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id); + try { + instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server); + } catch (RuntimeException $exception) { + if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) { + throw $exception; + } } } } diff --git a/app/Support/DomainUrlParts.php b/app/Support/DomainUrlParts.php index 2e6da7868c..c86d5fa9a9 100644 --- a/app/Support/DomainUrlParts.php +++ b/app/Support/DomainUrlParts.php @@ -4,11 +4,11 @@ namespace App\Support; class DomainUrlParts { - public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string + public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string { $scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https'; $host = trim($host); - $port = trim($port); + $port = trim((string) $port); $path = trim($path); if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a003ec40d..c3bd4a2238 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4152,11 +4152,12 @@ function coolifyHelperImage(): string function getHelperVersion(): string { - $settings = instanceSettings(); + if (isDev()) { + $devHelperVersion = InstanceSettings::query()->whereKey(0)->value('dev_helper_version'); - // In development mode, use the dev_helper_version if set, otherwise fallback to config - if (isDev() && ! empty($settings->dev_helper_version)) { - return $settings->dev_helper_version; + if (! empty($devHelperVersion)) { + return $devHelperVersion; + } } return config('constants.coolify.helper_version'); diff --git a/config/constants.php b/config/constants.php index a406dd0ea7..ca05db48f4 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.10', + 'version' => env('COOLIFY_VERSION') ?: '4.3.11', 'helper_version' => '1.0.15', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 567cfbeebe..94330bbcec 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -2,11 +2,11 @@ # https://hub.docker.com/_/alpine ARG BASE_IMAGE=alpine:3.21 # https://download.docker.com/linux/static/stable/ -ARG DOCKER_VERSION=28.0.0 +ARG DOCKER_VERSION=29.7.2 # https://github.com/docker/compose/releases -ARG DOCKER_COMPOSE_VERSION=2.38.2 +ARG DOCKER_COMPOSE_VERSION=5.5.0 # https://github.com/docker/buildx/releases -ARG DOCKER_BUILDX_VERSION=0.25.0 +ARG DOCKER_BUILDX_VERSION=0.36.1 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 440ad36160..d4e5b5c8c9 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.10" + "version": "4.3.11" }, "nightly": { "version": "4.4-rc.1" diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 0e4350f50f..d63c1953f2 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -334,12 +334,15 @@ step++; } else { submitting = true; - submitForm().then((result) => { - submitting = false; - modalOpen = false; - resetModal(); - }).catch(() => { - submitting = false; + modalOpen = false; + $nextTick(() => { + submitForm().then((result) => { + submitting = false; + resetModal(); + }).catch(() => { + submitting = false; + modalOpen = true; + }); }); } "> @@ -388,17 +391,21 @@ $wire.dispatch(dispatchEventType, dispatchEventMessage); } submitting = true; - submitForm().then((result) => { - submitting = false; - if (result === true) { - modalOpen = false; - resetModal(); - } else { - passwordError = result; - password = ''; - } - }).catch(() => { - submitting = false; + modalOpen = false; + $nextTick(() => { + submitForm().then((result) => { + submitting = false; + if (result === true) { + resetModal(); + } else { + modalOpen = true; + passwordError = result; + password = ''; + } + }).catch(() => { + submitting = false; + modalOpen = true; + }); }); "> diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index 4adbcf8ff1..128f2f2627 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 10aa21200a..6c20d0a46d 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 05e5792fe2..c228f39e59 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 81daa100fe..f856285adb 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/scripts/dev-helper b/scripts/dev-helper new file mode 100755 index 0000000000..1e1bfae27b --- /dev/null +++ b/scripts/dev-helper @@ -0,0 +1,280 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +REPOSITORY=${HELPER_IMAGE_REPOSITORY:-docker.io/coollabsio/coolify-helper} +DEFAULT_TAG="dev-$(git -C "$ROOT_DIR" rev-parse --short HEAD)" +COOLIFY_CONTAINER=${COOLIFY_CONTAINER:-coolify} + +usage() { + cat < [tag] + +Commands: + build [tag] Build the local helper image + use [tag] Configure local Coolify to use an existing helper image + verify [tag] Verify tools and run a Docker Compose socket smoke test + deploy [tag] Deploy seeded Dockerfile, Compose, and Nixpacks applications + reset Clear the local helper version override + test [tag] Build, select, verify, and run real seeded deployments + +Default tag: $DEFAULT_TAG +EOF +} + +require_local_coolify() { + local is_dev + + if ! docker inspect "$COOLIFY_CONTAINER" >/dev/null 2>&1; then + echo "Coolify container '$COOLIFY_CONTAINER' is not running." >&2 + exit 1 + fi + + is_dev=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo isDev() ? "yes" : "no";' 2>/dev/null | tail -1) + if [[ $is_dev != yes ]]; then + echo "The running Coolify instance is not in development mode." >&2 + exit 1 + fi +} + +validate_tag() { + local tag=$1 + + if [[ ! $tag =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo "Invalid Docker tag: $tag" >&2 + exit 1 + fi +} + +image_for() { + echo "${REPOSITORY}:$1" +} + +build_helper() { + local tag=$1 + local image + image=$(image_for "$tag") + + echo "Building $image" + docker build --progress=plain -f "$ROOT_DIR/docker/coolify-helper/Dockerfile" -t "$image" "$ROOT_DIR" +} + +use_helper() { + local tag=$1 + local image + image=$(image_for "$tag") + + require_local_coolify + docker image inspect "$image" >/dev/null + docker exec -e DEV_HELPER_VERSION="$tag" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + App\Models\InstanceSettings::findOrFail(0)->update([ + "dev_helper_version" => getenv("DEV_HELPER_VERSION"), + ]); + ' >/dev/null + + local selected + selected=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo getHelperVersion();' 2>/dev/null | tail -1) + if [[ $selected != "$tag" ]]; then + echo "Coolify selected helper '$selected' instead of '$tag'." >&2 + exit 1 + fi + + echo "Coolify now uses $image" +} + +verify_helper() { + local tag=$1 + local image project compose + image=$(image_for "$tag") + project="coolify-helper-${tag//[^A-Za-z0-9_-]/-}-smoke" + compose=$'services:\n app:\n image: alpine:3.21\n command: ["sh", "-c", "sleep 30"]\n' + + docker image inspect "$image" >/dev/null + + docker run --rm "$image" docker --version + docker run --rm "$image" docker compose version + docker run --rm "$image" docker buildx version + docker run --rm "$image" pack version + docker run --rm "$image" nixpacks --version + docker run --rm "$image" railpack --version + + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker version --format 'client={{.Client.Version}} server={{.Server.Version}} api={{.Client.APIVersion}}/{{.Server.APIVersion}}' + + cleanup_compose() { + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - down --remove-orphans >/dev/null 2>&1 || true + } + trap cleanup_compose RETURN + + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - up -d --wait + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - ps --format json + cleanup_compose + trap - RETURN + + if [[ -n $(docker ps -aq --filter "label=com.docker.compose.project=$project") ]]; then + echo "Compose smoke-test resources were not removed." >&2 + exit 1 + fi + + echo "Helper verification passed for $image" +} + +queue_deployment() { + local application_uuid=$1 + + docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $application = App\Models\Application::query() + ->where("uuid", getenv("APPLICATION_UUID")) + ->firstOrFail(); + $deploymentUuid = (string) Illuminate\Support\Str::uuid(); + queue_application_deployment( + application: $application, + deployment_uuid: $deploymentUuid, + force_rebuild: true, + no_questions_asked: true, + ); + echo "DEPLOYMENT_UUID={$deploymentUuid}"; + ' 2>/dev/null | sed -n 's/^DEPLOYMENT_UUID=//p' | tail -1 +} + +wait_for_deployment() { + local deployment_uuid=$1 + local expected_image=$2 + local status + + for _ in $(seq 1 150); do + status=$(docker exec -e DEPLOYMENT_UUID="$deployment_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $deployment = App\Models\ApplicationDeploymentQueue::query() + ->where("deployment_uuid", getenv("DEPLOYMENT_UUID")) + ->first(); + echo $deployment?->status ?? "missing"; + ' 2>/dev/null | tail -1) + + case "$status" in + finished) + break + ;; + failed|cancelled|missing) + echo "Deployment $deployment_uuid ended with status: $status" >&2 + return 1 + ;; + esac + + sleep 4 + done + + if [[ $status != finished ]]; then + echo "Deployment $deployment_uuid timed out with status: $status" >&2 + return 1 + fi + + local used_expected_helper + used_expected_helper=$(docker exec \ + -e DEPLOYMENT_UUID="$deployment_uuid" \ + -e EXPECTED_HELPER_IMAGE="$expected_image" \ + "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $deployment = App\Models\ApplicationDeploymentQueue::query() + ->where("deployment_uuid", getenv("DEPLOYMENT_UUID")) + ->firstOrFail(); + $logs = collect(json_decode($deployment->logs, true)) + ->pluck("output") + ->implode("\n"); + echo str_contains( + $logs, + "Preparing container with helper image: ".getenv("EXPECTED_HELPER_IMAGE"), + ) ? "yes" : "no"; + ' 2>/dev/null | tail -1) + + if [[ $used_expected_helper != yes ]]; then + echo "Deployment $deployment_uuid did not use $expected_image." >&2 + return 1 + fi +} + +deploy_examples() { + local tag=$1 + local image application_uuid deployment_uuid fqdn + image=$(image_for "$tag") + + require_local_coolify + use_helper "$tag" + + for application_uuid in dockerfile docker-compose nodejs; do + echo "Deploying seeded application: $application_uuid" + deployment_uuid=$(queue_deployment "$application_uuid") + if [[ -z $deployment_uuid ]]; then + echo "Could not queue $application_uuid." >&2 + exit 1 + fi + + wait_for_deployment "$deployment_uuid" "$image" + + fqdn=$(docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + echo App\Models\Application::query() + ->where("uuid", getenv("APPLICATION_UUID")) + ->value("fqdn") ?? ""; + ' 2>/dev/null | tail -1) + + if [[ -n $fqdn ]]; then + curl --fail --silent --show-error --output /dev/null "$fqdn" + fi + + echo "Deployment passed: $application_uuid ($deployment_uuid)" + done +} + +reset_helper() { + require_local_coolify + docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute ' + App\Models\InstanceSettings::findOrFail(0)->update([ + "dev_helper_version" => null, + ]); + ' >/dev/null + echo "Development helper override cleared." +} + +command=${1:-help} +tag=${2:-$DEFAULT_TAG} + +case "$command" in + build) + validate_tag "$tag" + build_helper "$tag" + ;; + use) + validate_tag "$tag" + use_helper "$tag" + ;; + verify) + validate_tag "$tag" + verify_helper "$tag" + ;; + deploy) + validate_tag "$tag" + deploy_examples "$tag" + ;; + reset) + reset_helper + ;; + test) + validate_tag "$tag" + build_helper "$tag" + use_helper "$tag" + verify_helper "$tag" + deploy_examples "$tag" + ;; + help|-h|--help) + usage + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php index 3b3721545f..1847c00221 100644 --- a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php +++ b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php @@ -254,4 +254,58 @@ describe('other application creation endpoints use_build_secrets', function () { expect($application->settings->use_build_secrets)->toBeTrue(); }); + + test('creates an application from a system-wide GitHub App owned by another team', function () { + $ownerTeam = Team::factory()->create(); + $privateKey = PrivateKey::create([ + 'name' => 'System-wide GitHub App Key', + 'private_key' => buildSecretsGithubPrivateKey(), + 'team_id' => $ownerTeam->id, + ]); + $githubApp = GithubApp::create([ + 'name' => 'System-wide GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 54321, + 'installation_id' => 9876, + 'client_id' => 'system-wide-client-id', + 'client_secret' => 'system-wide-client-secret', + 'webhook_secret' => 'system-wide-webhook-secret', + 'private_key_id' => $privateKey->id, + 'team_id' => $ownerTeam->id, + 'is_system_wide' => true, + 'is_public' => false, + ]); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [ + 'Date' => now()->toRfc7231String(), + ]), + 'https://api.github.com/app/installations/9876/access_tokens' => Http::response([ + 'token' => 'github-installation-token', + ], 201), + 'https://api.github.com/repos/coolify/system-wide-test' => Http::response([ + 'id' => 654321, + ]), + ]); + + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/private-github-app', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'github_app_uuid' => $githubApp->uuid, + 'git_repository' => 'coolify/system-wide-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->source_id)->toBe($githubApp->id) + ->and($application->environment_id)->toBe($this->environment->id); + }); }); diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 50c71fe958..cac3b29a39 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 @@ it('adds multiple domains without replacing existing ones', function () { ->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 @@ it('blocks adding a domain with bad dns until the user continues', function () { $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 @@ it('blocks adding a domain with bad dns until the user continues', function () { '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 @@ it('persists dns status after checking a domain', function () { ->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/ApplicationPreviewVolumeCleanupTest.php b/tests/Feature/ApplicationPreviewVolumeCleanupTest.php new file mode 100644 index 0000000000..7f59f689fc --- /dev/null +++ b/tests/Feature/ApplicationPreviewVolumeCleanupTest.php @@ -0,0 +1,120 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'user' => 'deploy', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'build_pack' => 'dockerfile', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->preview = ApplicationPreview::create([ + 'uuid' => 'preview-volume-cleanup-test', + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/repository/pull/42', + ]); +}); + +it('deletes only named volumes that have the preview suffix enabled', function () { + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'shared-cache', + 'mount_path' => '/cache', + 'host_path' => null, + 'is_preview_suffix_enabled' => false, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'seed-data', + 'mount_path' => '/seed', + 'host_path' => '/srv/seed', + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(['*' => Process::result(output: '')]); + + $this->preview->forceDelete(); + + Process::assertRanTimes(fn ($process) => str_contains($process->command, 'docker volume rm'), 1); + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'")); + Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker volume rm -f 'app-data-pr-42'")); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'shared-cache')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'seed-data')); +}); + +it('reports Docker volume removal failures and keeps the preview record', function () { + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(['*' => Process::result(errorOutput: 'volume is in use', exitCode: 1)]); + + expect(fn () => $this->preview->forceDelete()) + ->toThrow(RuntimeException::class, 'volume is in use'); + + expect(ApplicationPreview::find($this->preview->id))->not->toBeNull(); +}); + +it('continues removing preview volumes when an earlier volume is already absent', function () { + $this->application->persistentStorages()->create([ + 'name' => 'already-removed', + 'mount_path' => '/removed', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(function ($process) { + if (str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")) { + return Process::result( + errorOutput: 'Error response from daemon: volume already-removed-pr-42 not found', + exitCode: 1, + ); + } + + return Process::result(output: 'app-data-pr-42'); + }); + + $this->preview->forceDelete(); + + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")); + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'")); + expect(ApplicationPreview::find($this->preview->id))->toBeNull(); +}); diff --git a/tests/Feature/CheckDomainDnsJobTest.php b/tests/Feature/CheckDomainDnsJobTest.php new file mode 100644 index 0000000000..74502268a0 --- /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/DevHelperVersionValidationTest.php b/tests/Feature/DevHelperVersionValidationTest.php index 03316598c3..0a91141850 100644 --- a/tests/Feature/DevHelperVersionValidationTest.php +++ b/tests/Feature/DevHelperVersionValidationTest.php @@ -88,3 +88,22 @@ test('buildHelperImage refuses previously stored invalid version', function () { ->call('buildHelperImage') ->assertDispatched('error'); }); + +test('development helper version is read fresh for queue workers', function () { + config(['app.env' => 'local']); + + InstanceSettings::findOrFail(0)->update(['dev_helper_version' => 'first']); + expect(getHelperVersion())->toBe('first'); + + InstanceSettings::query()->whereKey(0)->update(['dev_helper_version' => 'second']); + + expect(getHelperVersion())->toBe('second'); +}); + +test('development helper build uses the configured helper repository', function () { + $component = file_get_contents(app_path('Livewire/Settings/Index.php')); + + expect($component) + ->toContain('$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");') + ->not->toContain('"ghcr.io/coollabsio/coolify-helper:{$version}"'); +}); diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php index aed8eb85c8..a9ac104c71 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 @@ it('stops querying DNS servers after finding a matching IP', function (string $r 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/ModalScrollLockTest.php b/tests/Feature/ModalScrollLockTest.php index 7863325a20..17d293519d 100644 --- a/tests/Feature/ModalScrollLockTest.php +++ b/tests/Feature/ModalScrollLockTest.php @@ -7,3 +7,11 @@ test('confirmation modal closes before dispatching an event that can open anothe '/if \(dispatchEvent\) \{\s*modalOpen = false;\s*\$nextTick\(\(\) => \$wire\.dispatch\(dispatchEventType, dispatchEventMessage\)\);/s' ); }); + +test('confirmation modal releases its scroll lock before submitting a destructive action', function () { + $modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php')); + + expect($modal) + ->toMatch('/submitting = true;\s*modalOpen = false;\s*\$nextTick\(\(\) => \{\s*submitForm\(\)/s') + ->toMatch('/if \(result === true\) \{\s*resetModal\(\);/s'); +}); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 899aa521f8..76ca4f2a8a 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 @@ it('does not restore stale dns status when a removed service domain is re-added' ], ]); - 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 @@ it('does not restore stale dns status when a removed service domain is re-added' ->assertHasNoErrors() ->assertDispatched('success'); + $component->call('pollDnsChecks'); + $this->apiApp->refresh(); expect(explode(',', (string) $this->apiApp->fqdn)) @@ -564,6 +567,39 @@ it('hides dns message text when service domain dns status is ok', function () { ->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/TerminalContainerListTest.php b/tests/Feature/TerminalContainerListTest.php new file mode 100644 index 0000000000..ec64da51c9 --- /dev/null +++ b/tests/Feature/TerminalContainerListTest.php @@ -0,0 +1,39 @@ +makePartial(); + $server->forceFill(['uuid' => $uuid, 'name' => $name]); + $server->shouldReceive('isFunctional')->once()->andReturnTrue(); + $server->shouldReceive('loadAllContainers')->once()->andReturn(collect($containers)); + + return $server; +} + +it('keeps containers with the same name on different servers as distinct terminal targets', function () { + $component = new Index; + $component->servers = new Collection([ + terminalServer('pulse-uuid', 'Pulse', [ + ['Names' => 'coolify-proxy', 'State' => 'running'], + ['Names' => 'coolify-sentinel', 'State' => 'running'], + ]), + terminalServer('forge-uuid', 'Forge', [ + ['Names' => 'coolify-proxy', 'State' => 'running'], + ['Names' => 'coolify-sentinel', 'State' => 'running'], + ]), + ]); + + $component->loadContainers(); + + expect($component->containers)->toHaveCount(4) + ->and(collect($component->containers)->pluck('uuid')->all())->toBe([ + 'pulse-uuid:coolify-proxy', + 'forge-uuid:coolify-proxy', + 'pulse-uuid:coolify-sentinel', + 'forge-uuid:coolify-sentinel', + ]); +}); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index b4f78d9e0a..aa7da543be 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -6,6 +6,7 @@ use App\Jobs\VolumeBackupJob; 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\ScheduledVolumeBackup; 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 @@ it('creates a scheduled backup with a preselected volume from the shared modal', ->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); diff --git a/tests/Unit/DevHelperScriptTest.php b/tests/Unit/DevHelperScriptTest.php new file mode 100644 index 0000000000..eee1803826 --- /dev/null +++ b/tests/Unit/DevHelperScriptTest.php @@ -0,0 +1,19 @@ +toBeFile() + ->and(is_executable($script))->toBeTrue(); + + exec('bash -n '.escapeshellarg($script), $output, $exitCode); + + expect($exitCode)->toBe(0) + ->and(file_get_contents($script)) + ->toContain('build)') + ->toContain('use)') + ->toContain('verify)') + ->toContain('deploy)') + ->toContain('reset)') + ->toContain('test)'); +}); diff --git a/tests/Unit/DockerCliPackagingTest.php b/tests/Unit/DockerCliPackagingTest.php new file mode 100644 index 0000000000..71fa7078e1 --- /dev/null +++ b/tests/Unit/DockerCliPackagingTest.php @@ -0,0 +1,12 @@ +toContain('ARG DOCKER_VERSION=29.7.2') + ->toContain('ARG DOCKER_COMPOSE_VERSION=5.5.0') + ->toContain('ARG DOCKER_BUILDX_VERSION=0.36.1') + ->toContain('https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz') + ->toContain('https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz') + ->toMatch('/chmod \+x [^\n]*\/usr\/bin\/docker/'); +}); diff --git a/tests/Unit/DomainUrlPartsTest.php b/tests/Unit/DomainUrlPartsTest.php index 086599a1a2..5c2994934c 100644 --- a/tests/Unit/DomainUrlPartsTest.php +++ b/tests/Unit/DomainUrlPartsTest.php @@ -28,3 +28,8 @@ it('defaults empty values for an invalid URL', function () { it('preserves an explicitly configured default port', function () { expect(DomainUrlParts::split('https://app.example.com:443')['port'])->toBe('443'); }); + +it('composes a domain when Livewire hydrates an empty numeric port as null', function () { + expect(DomainUrlParts::compose('https', 'app.example.com', null)) + ->toBe('https://app.example.com'); +}); diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 659d233122..2fafa678e8 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.10'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.10') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.11'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.11') ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); diff --git a/versions.json b/versions.json index 440ad36160..d4e5b5c8c9 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.10" + "version": "4.3.11" }, "nightly": { "version": "4.4-rc.1"