From 0b843bb07c3eed3d63f35bb7bb9af6421f35a73e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:08:56 +0200 Subject: [PATCH] fix(compose): normalize service-name keys for domains and env vars (#11040) --- app/Jobs/ApplicationDeploymentJob.php | 37 ++- app/Livewire/Project/Application/General.php | 87 ++++-- .../Project/Application/PreviewsCompose.php | 76 +++-- app/Models/Application.php | 54 ++-- app/Models/ApplicationPreview.php | 103 +++++-- bootstrap/helpers/docker.php | 37 ++- bootstrap/helpers/domains.php | 279 ++++++++++++++++++ bootstrap/helpers/parsers.php | 120 ++++---- bootstrap/helpers/services.php | 29 +- bootstrap/helpers/shared.php | 76 ++++- ...licationParserDockerComposeDomainsTest.php | 80 +++++ tests/Feature/ComposePreviewFqdnTest.php | 112 +++++++ .../ServiceDomainPortCorruptionTest.php | 93 ++++++ .../Unit/ApplicationParserStringableTest.php | 15 +- tests/Unit/ComposeServiceNameHelpersTest.php | 224 ++++++++++++++ tests/Unit/DomainUrlHelpersTest.php | 93 ++++++ tests/Unit/GetFqdnWithoutPortTest.php | 51 ++++ tests/Unit/TraefikServiceNameSegmentTest.php | 97 ++++++ 18 files changed, 1443 insertions(+), 220 deletions(-) create mode 100644 tests/Feature/ServiceDomainPortCorruptionTest.php create mode 100644 tests/Unit/ComposeServiceNameHelpersTest.php create mode 100644 tests/Unit/DomainUrlHelpersTest.php create mode 100644 tests/Unit/GetFqdnWithoutPortTest.php create mode 100644 tests/Unit/TraefikServiceNameSegmentTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3e415c729..3d98b0f2b 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1343,19 +1343,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { - $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); + $domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []); // Generate SERVICE_FQDN & SERVICE_URL for dockercompose + // Env keys always use underscore-normalized names so hyphen/dot storage keys stay valid. foreach ($domains as $forServiceName => $domain) { - $parsedDomain = data_get($domain, 'domain'); + $parsedDomain = composeDomainEntryString($domain); if (filled($parsedDomain)) { $parsedDomain = str($parsedDomain)->explode(',')->first(); $coolifyUrl = Url::fromString($parsedDomain); $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); - $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); + $envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn); } } @@ -1413,19 +1415,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { - $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); + $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []); // Generate SERVICE_FQDN & SERVICE_URL for dockercompose foreach ($domains as $forServiceName => $domain) { - $parsedDomain = data_get($domain, 'domain'); + $parsedDomain = composeDomainEntryString($domain); if (filled($parsedDomain)) { $parsedDomain = str($parsedDomain)->explode(',')->first(); $coolifyUrl = Url::fromString($parsedDomain); $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $envs->push('SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.$coolifyUrl->__toString()); - $envs->push('SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.$coolifyFqdn); + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); + $envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn); } } @@ -1664,17 +1667,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments - $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); + $domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []); foreach ($domains as $forServiceName => $domain) { - $parsedDomain = data_get($domain, 'domain'); + $parsedDomain = composeDomainEntryString($domain); if (filled($parsedDomain)) { $parsedDomain = str($parsedDomain)->explode(',')->first(); $coolifyUrl = Url::fromString($parsedDomain); $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $envs_dict['SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyUrl->__toString()); - $envs_dict['SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyFqdn); + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); + $envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString()); + $envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn); } } } else { @@ -1686,17 +1690,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains - $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); + $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []); foreach ($domains as $forServiceName => $domain) { - $parsedDomain = data_get($domain, 'domain'); + $parsedDomain = composeDomainEntryString($domain); if (filled($parsedDomain)) { $parsedDomain = str($parsedDomain)->explode(',')->first(); $coolifyUrl = Url::fromString($parsedDomain); $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $envs_dict['SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyUrl->__toString()); - $envs_dict['SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyFqdn); + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); + $envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString()); + $envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn); } } } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 3e5a51944..0c57daf18 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -301,12 +301,7 @@ class General extends Component } $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; // Convert service names with dots and dashes to use underscores for HTML form binding - $sanitizedDomains = []; - foreach ($this->parsedServiceDomains as $serviceName => $domain) { - $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); - $sanitizedDomains[$sanitizedKey] = $domain; - } - $this->parsedServiceDomains = $sanitizedDomains; + $this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains); $this->customLabels = $this->application->parseContainerLabels(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) { @@ -520,12 +515,7 @@ class General extends Component $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; // Convert service names with dots and dashes to use underscores for HTML form binding - $sanitizedDomains = []; - foreach ($this->parsedServiceDomains as $serviceName => $domain) { - $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); - $sanitizedDomains[$sanitizedKey] = $domain; - } - $this->parsedServiceDomains = $sanitizedDomains; + $this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains); $showToast && $this->dispatch('success', 'Docker compose file loaded.'); $this->dispatch('compose_loaded'); @@ -551,22 +541,14 @@ class General extends Component $uuid = new_public_id(); $domain = generateUrl(server: $this->application->destination->server, random: $uuid); - $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); + $sanitizedKey = normalizeComposeServiceName($serviceName); $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; // Convert back to original service names for storage $originalDomains = []; + $composeServiceNames = collect(data_get($this->parsedServices, 'services', []))->keys()->map(fn ($name) => (string) $name)->values()->all(); foreach ($this->parsedServiceDomains as $key => $value) { - // Find the original service name by checking parsed services - $originalServiceName = $key; - if (isset($this->parsedServices['services'])) { - foreach ($this->parsedServices['services'] as $originalName => $service) { - if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) { - $originalServiceName = $originalName; - break; - } - } - } + $originalServiceName = findComposeServiceName((string) $key, $composeServiceNames) ?? (string) $key; $originalDomains[$originalServiceName] = $value; } @@ -858,9 +840,10 @@ class General extends Component foreach ($this->parsedServiceDomains as $serviceName => $service) { $this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain')); } - $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); + $originalDomains = $this->composeDomainsForStorage(); + $this->application->docker_compose_domains = json_encode($originalDomains); if ($this->application->isDirty('docker_compose_domains')) { - foreach ($this->parsedServiceDomains as $service) { + foreach ($originalDomains as $service) { $domain = data_get($service, 'domain'); if ($domain) { if (! validateDNSEntry($domain, $this->application->destination->server)) { @@ -985,4 +968,58 @@ class General extends Component '{workdir}/.env' ); } + + private function composeDomainsForStorage(): array + { + return rekeyComposeDomainsToServiceNames( + $this->parsedServiceDomains, + collect(data_get($this->parsedServices, 'services', []))->keys(), + ); + } + + /** + * Collapse domain map keys to underscore form keys for Livewire/HTML binding. + * When twin keys exist, prefer a filled domain over a blank one. + * + * @param array $domains + * @return array + */ + private function sanitizeParsedServiceDomainsForForm(array $domains): array + { + $sanitizedDomains = []; + + foreach ($domains as $serviceName => $domain) { + $sanitizedKey = normalizeComposeServiceName((string) $serviceName); + if (! array_key_exists($sanitizedKey, $sanitizedDomains)) { + $sanitizedDomains[$sanitizedKey] = $domain; + + continue; + } + + $existing = $sanitizedDomains[$sanitizedKey]; + if (is_object($existing)) { + $existing = (array) $existing; + } + if (is_object($domain)) { + $domain = (array) $domain; + } + if (! is_array($existing)) { + $existing = ['domain' => $existing]; + } + if (! is_array($domain)) { + $domain = ['domain' => $domain]; + } + + $merged = array_merge($existing, $domain); + $merged['domain'] = preferComposeDomainValue( + $existing['domain'] ?? null, + false, + $domain['domain'] ?? null, + false, + ); + $sanitizedDomains[$sanitizedKey] = $merged; + } + + return $sanitizedDomains; + } } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index 48392a742..0fdcf4615 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -39,13 +39,7 @@ class PreviewsCompose extends Component ]); $this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain); - - $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; - $docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? []; - $docker_compose_domains[$this->serviceName]['domain'] = $this->domain; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); + $this->persistPreviewDomain($this->domain); $this->dispatch('update_links'); $this->dispatch('success', 'Domain saved.'); } catch (\Throwable $e) { @@ -58,12 +52,8 @@ class PreviewsCompose extends Component try { $this->authorize('update', $this->preview->application); - $domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []); - $domain = $domains->first(function ($_, $key) { - return $key === $this->serviceName; - }); - - $domain_string = data_get($domain, 'domain'); + $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)) { @@ -111,14 +101,8 @@ class PreviewsCompose extends Component $preview_fqdn = implode(',', $preview_fqdns); } - // Save the generated domain $this->domain = $preview_fqdn; - $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; - $docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? []; - $docker_compose_domains[$this->serviceName]['domain'] = $this->domain; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); + $this->persistPreviewDomain($this->domain); $this->dispatch('update_links'); $this->dispatch('success', 'Domain generated.'); @@ -126,4 +110,56 @@ class PreviewsCompose extends Component 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/Models/Application.php b/app/Models/Application.php index ce86ba13b..83d8811d7 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2082,34 +2082,7 @@ class Application extends BaseModel $this->save(); $parsedServices = $this->parse(); if ($this->docker_compose_domains) { - $decoded = json_decode($this->docker_compose_domains, true); - $json = collect(is_array($decoded) ? $decoded : []); - $normalized = collect(); - foreach ($json as $key => $value) { - $normalizedKey = (string) str($key)->replace('-', '_')->replace('.', '_'); - $normalized->put($normalizedKey, $value); - } - $json = $normalized; - $services = collect(data_get($parsedServices, 'services', [])); - foreach ($services as $name => $service) { - if (str($name)->contains('-') || str($name)->contains('.')) { - $replacedName = str($name)->replace('-', '_')->replace('.', '_'); - $services->put((string) $replacedName, $service); - $services->forget((string) $name); - } - } - $names = collect($services)->keys()->toArray(); - $jsonNames = $json->keys()->toArray(); - $diff = array_diff($jsonNames, $names); - $json = $json->filter(function ($value, $key) use ($diff) { - return ! in_array($key, $diff); - }); - if ($json) { - $this->docker_compose_domains = json_encode($json); - } else { - $this->docker_compose_domains = null; - } - $this->save(); + $this->reconcileDockerComposeDomains($parsedServices); } return [ @@ -2126,6 +2099,31 @@ class Application extends BaseModel } } + private function reconcileDockerComposeDomains(mixed $parsedServices): void + { + $services = collect(data_get($parsedServices, 'services', [])); + $serviceNames = $services->keys()->map(fn ($name) => (string) $name)->all(); + + if ($serviceNames === []) { + return; + } + + $decoded = json_decode($this->docker_compose_domains, true); + $rekeyed = rekeyComposeDomainsToServiceNames( + is_array($decoded) ? $decoded : [], + $serviceNames, + ); + + $domains = collect($rekeyed)->filter( + fn ($value, $key) => findComposeServiceName((string) $key, $serviceNames) !== null + ); + + $this->docker_compose_domains = $domains->isNotEmpty() + ? json_encode($domains->all()) + : null; + $this->save(); + } + public function parseContainerLabels(?ApplicationPreview $preview = null) { $customLabels = data_get($this, 'custom_labels'); diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 6e4b696d5..6998211ee 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -124,34 +124,55 @@ class ApplicationPreview extends BaseModel public function generate_preview_fqdn_compose() { - $services = collect(json_decode($this->application->docker_compose_domains)) ?? collect(); - $docker_compose_domains = data_get($this, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true) ?? []; + $applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []; + $previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: []; - // Get all services from the parsed compose file to ensure all services have entries - $parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id); - if (isset($parsedServices['services'])) { - foreach ($parsedServices['services'] as $serviceName => $service) { - if (! isDatabaseImage(data_get($service, 'image'))) { - // Remove PR suffix from service name to get original service name - $originalServiceName = str($serviceName)->replaceLast('-pr-'.$this->pull_request_id, '')->toString(); + $composeServiceNames = $this->composeServiceNamesForPreview(); + // Canonical compose names first; collapse leftover domain-map twins when parse is empty/missing. + $domainKeys = collect(array_keys($applicationDomains)) + ->merge(array_keys($previewDomains)) + ->map(fn ($name) => (string) $name); + $unmappedDomainKeys = $domainKeys + ->reject(fn (string $key) => findComposeServiceName($key, $composeServiceNames) !== null) + ->all(); + $knownServiceNames = collect($composeServiceNames) + ->merge(preferredComposeServiceNamesFromDomainKeys( + $composeServiceNames === [] ? $domainKeys->all() : $unmappedDomainKeys + )) + ->unique() + ->values() + ->all(); - // Ensure all services have an entry, even if empty - if (! $services->has($originalServiceName)) { - $services->put($originalServiceName, ['domain' => '']); - } - } + $applicationDomains = rekeyComposeDomainsToServiceNames($applicationDomains, $knownServiceNames); + $previewDomains = rekeyComposeDomainsToServiceNames($previewDomains, $knownServiceNames); + + // Ensure every non-database compose service has a domain slot (empty if unset). + foreach ($composeServiceNames as $serviceName) { + if (! array_key_exists($serviceName, $applicationDomains)) { + $applicationDomains[$serviceName] = ['domain' => '']; } } - foreach ($services as $service_name => $service_config) { - $domain_string = data_get($service_config, 'domain'); + $serviceNames = collect(array_keys($applicationDomains)) + ->merge($composeServiceNames) + ->map(fn ($name) => (string) $name) + ->unique() + ->values() + ->all(); + + $docker_compose_domains = []; + 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)) { - // Ensure service has an empty domain entry for form binding - $docker_compose_domains[$service_name]['domain'] = ''; + $docker_compose_domains = putComposeServiceDomain( + $docker_compose_domains, + $service_name, + '', + $serviceNames, + ); continue; } @@ -180,19 +201,22 @@ class ApplicationPreview extends BaseModel $preview_domains[] = $preview_fqdn; } - if (! empty($preview_domains)) { - $docker_compose_domains[$service_name]['domain'] = implode(',', $preview_domains); - } else { - // Ensure service has an empty domain entry for form binding - $docker_compose_domains[$service_name]['domain'] = ''; - } + $docker_compose_domains = putComposeServiceDomain( + $docker_compose_domains, + $service_name, + ! empty($preview_domains) ? implode(',', $preview_domains) : '', + $serviceNames, + ); } + // Drop any leftover twin keys that were not rewritten above. + $docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames); + $this->docker_compose_domains = json_encode($docker_compose_domains); // Populate fqdn from generated domains so webhook notifications can read it $allDomains = collect($docker_compose_domains) - ->pluck('domain') + ->map(fn ($entry) => composeDomainEntryString($entry)) ->filter(fn ($d) => ! empty($d)) ->flatMap(fn ($d) => explode(',', $d)) ->implode(','); @@ -201,4 +225,31 @@ class ApplicationPreview extends BaseModel $this->save(); } + + /** + * Original compose service names for this preview (PR suffix stripped), excluding database images. + * + * @return list + */ + private function composeServiceNamesForPreview(): array + { + $parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id); + $services = data_get($parsedServices, 'services', []); + if (! is_iterable($services)) { + return []; + } + + $names = []; + foreach ($services as $serviceName => $service) { + if (isDatabaseImage(data_get($service, 'image'))) { + continue; + } + + $names[] = str((string) $serviceName) + ->replaceLast('-pr-'.$this->pull_request_id, '') + ->toString(); + } + + return array_values(array_unique($names)); + } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 0440ae352..52783ff4d 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -10,6 +10,37 @@ use Illuminate\Support\Str; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; +/** + * Stable short hash for compose service names used in Traefik router/service IDs. + * Keeps api.test vs api-test distinct after label-safe normalization. + */ +function traefikServiceNameHash(string $serviceName): string +{ + return substr(md5($serviceName), 0, 4); +} + +/** + * Label-safe Traefik router/service name segment for a compose service. + * Dots (and other non [a-zA-Z0-9-]) break Docker/Traefik label paths; always append a hash + * so normalized names like api.test and api-test never collide. + */ +function traefikSafeServiceNameSegment(string $serviceName): string +{ + $normalized = str($serviceName) + ->replace('.', '-') + ->replace('_', '-') + ->replaceMatches('/[^a-zA-Z0-9-]+/', '-') + ->replaceMatches('/-+/', '-') + ->trim('-') + ->toString(); + + if ($normalized === '') { + $normalized = 'service'; + } + + return $normalized.'-'.traefikServiceNameHash($serviceName); +} + function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection { $containers = collect([]); @@ -507,8 +538,10 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $http_label = "http-{$loop}-{$uuid}"; $https_label = "https-{$loop}-{$uuid}"; if ($service_name) { - $http_label = "http-{$loop}-{$uuid}-{$service_name}"; - $https_label = "https-{$loop}-{$uuid}-{$service_name}"; + // Dots in service names split Traefik label paths; hash avoids api.test/api-test collisions. + $safeServiceName = traefikSafeServiceNameSegment($service_name); + $http_label = "http-{$loop}-{$uuid}-{$safeServiceName}"; + $https_label = "https-{$loop}-{$uuid}-{$safeServiceName}"; } if (str($image)->contains('ghost')) { $labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)"); diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index f3c5359f7..f3a20cb0f 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -1,6 +1,7 @@ count($conflicts) > 0, ]; } + +/** + * Normalize a compose service name the way Coolify historically did for env var keys + * (hyphens and dots → underscores). Used for comparison and SERVICE_* env names only — + * docker_compose_domains keys should use the original compose service name. + */ +function normalizeComposeServiceName(string $serviceName): string +{ + return str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); +} + +/** + * Resolve a candidate key (original or legacy-normalized) to the original compose service name. + * + * @param iterable $serviceNames + */ +function findComposeServiceName(string $candidate, iterable $serviceNames): ?string +{ + $names = collect($serviceNames)->map(fn ($name) => (string) $name)->values(); + + if ($names->containsStrict($candidate)) { + return $candidate; + } + + $normalized = normalizeComposeServiceName($candidate); + $matches = $names->filter( + fn ($name) => normalizeComposeServiceName($name) === $normalized + )->values(); + + return $matches->count() === 1 ? $matches->first() : null; +} + +/** + * Collapse domain-map keys that only differ by hyphen/dot/underscore into one preferred name. + * Prefers a key that still has `-` or `.` over a fully underscore-normalized twin. + * + * @param iterable $domainKeys + * @return list + */ +function preferredComposeServiceNamesFromDomainKeys(iterable $domainKeys): array +{ + $groups = []; + foreach ($domainKeys as $key) { + $key = (string) $key; + $groups[normalizeComposeServiceName($key)][] = $key; + } + + $preferred = []; + foreach ($groups as $normalized => $keys) { + $keys = array_values(array_unique($keys)); + $withSeparators = array_values(array_filter( + $keys, + fn (string $key) => $key !== $normalized + )); + $preferred[] = $withSeparators[0] ?? $keys[0]; + } + + return $preferred; +} + +/** + * Read domain string for a compose service from docker_compose_domains. + * Prefers a filled domain on the requested key; falls back to any filled twin + * (legacy underscore keys). Blank entries do not shadow filled twins. + * Uses collection key access (not dotted data_get) so names like "api.test" work. + * + * @param array|Collection $domains + */ +function getComposeServiceDomainString(array|Collection $domains, string $serviceName): ?string +{ + $domains = collect($domains); + $normalized = normalizeComposeServiceName($serviceName); + $matches = []; + + foreach ($domains as $key => $entry) { + $key = (string) $key; + if ($key !== $serviceName + && $key !== $normalized + && normalizeComposeServiceName($key) !== $normalized) { + continue; + } + + $matches[] = [ + 'key' => $key, + 'domain' => composeDomainEntryString($entry), + 'is_requested' => $key === $serviceName, + ]; + } + + if ($matches === []) { + return null; + } + + $filled = array_values(array_filter( + $matches, + fn (array $match) => filled($match['domain']) + )); + + if ($filled !== []) { + foreach ($filled as $match) { + if ($match['is_requested']) { + return $match['domain']; + } + } + + return $filled[0]['domain']; + } + + foreach ($matches as $match) { + if ($match['is_requested']) { + return $match['domain']; + } + } + + return $matches[0]['domain']; +} + +function composeDomainEntryString(mixed $entry): ?string +{ + if (is_object($entry)) { + $entry = (array) $entry; + } + + if (! is_array($entry)) { + return is_string($entry) ? $entry : null; + } + + $domain = $entry['domain'] ?? null; + + return is_string($domain) ? $domain : null; +} + +/** + * Choose which domain string wins when merging twin compose domain keys. + * Filled values always beat blanks; when both are filled, prefer the canonical key. + */ +function preferComposeDomainValue( + mixed $existingDomain, + bool $existingIsCanonical, + mixed $incomingDomain, + bool $incomingIsCanonical, +): mixed { + $existingFilled = filled($existingDomain); + $incomingFilled = filled($incomingDomain); + + if ($existingFilled && ! $incomingFilled) { + return $existingDomain; + } + + if ($incomingFilled && ! $existingFilled) { + return $incomingDomain; + } + + if ($existingFilled && $incomingFilled) { + if ($incomingIsCanonical && ! $existingIsCanonical) { + return $incomingDomain; + } + + return $existingDomain; + } + + // Both blank: keep canonical slot when possible. + if ($incomingIsCanonical) { + return $incomingDomain; + } + + return $existingDomain; +} + +/** + * Rekey docker_compose_domains to original compose service names. + * Merges legacy underscore/dot twin keys onto the canonical compose name. + * Never lets an empty canonical key wipe a filled legacy twin. + * + * @param array|Collection $domains + * @param iterable $serviceNames + * @return array + */ +function rekeyComposeDomainsToServiceNames(array|Collection $domains, iterable $serviceNames): array +{ + $domains = collect($domains); + $serviceNames = collect($serviceNames)->map(fn ($name) => (string) $name)->values(); + $rekeyed = []; + $canonicalDomainSources = []; + + foreach ($domains as $key => $value) { + $key = (string) $key; + $original = findComposeServiceName($key, $serviceNames) ?? $key; + $isCanonical = $key === $original; + + if (is_object($value)) { + $value = (array) $value; + } + if (! is_array($value)) { + $value = ['domain' => $value]; + } + + if (! isset($rekeyed[$original])) { + $rekeyed[$original] = $value; + $canonicalDomainSources[$original] = $isCanonical; + + continue; + } + + $existingDomain = $rekeyed[$original]['domain'] ?? null; + $incomingDomain = $value['domain'] ?? null; + $rekeyed[$original] = array_merge($rekeyed[$original], $value); + $rekeyed[$original]['domain'] = preferComposeDomainValue( + $existingDomain, + $canonicalDomainSources[$original], + $incomingDomain, + $isCanonical, + ); + $canonicalDomainSources[$original] = $canonicalDomainSources[$original] || $isCanonical; + } + + return $rekeyed; +} + +/** + * Resolve a SERVICE_FQDN_ / SERVICE_URL_ env fragment to a ServiceApplication by compose name. + * Handles legacy underscore fragments and dotted compose names (api.test vs API_TEST). + */ +function findServiceApplicationForEnvName(Service $resource, string $envServiceName): ?ServiceApplication +{ + $names = $resource->applications()->pluck('name'); + $resolved = findComposeServiceName($envServiceName, $names) + ?? findComposeServiceName(str($envServiceName)->replace('_', '-')->toString(), $names); + + if ($resolved === null) { + return null; + } + + return $resource->applications()->where('name', $resolved)->first(); +} + +/** + * Put/update a service domain entry under the original compose service name, + * removing legacy twin keys that resolve unambiguously to the same name. + * + * @param array|Collection $domains + * @param iterable $serviceNames + * @return array + */ +function putComposeServiceDomain( + array|Collection $domains, + string $serviceName, + ?string $domainString, + iterable $serviceNames = [], +): array { + $domains = collect($domains)->all(); + $names = collect($serviceNames)->map(fn ($name) => (string) $name); + $storageKey = findComposeServiceName($serviceName, $names) ?? $serviceName; + + $merged = ['domain' => $domainString]; + foreach (array_keys($domains) as $key) { + $key = (string) $key; + if ($key !== $storageKey && findComposeServiceName($key, $names) !== $storageKey) { + continue; + } + + $existing = $domains[$key]; + if (is_object($existing)) { + $existing = (array) $existing; + } + if (is_array($existing)) { + $merged = array_merge($existing, $merged); + } + + if ($key !== $storageKey) { + unset($domains[$key]); + } + } + + $domains[$storageKey] = $merged; + + return $domains; +} diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index ff1d6563e..34227e853 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -12,6 +12,7 @@ use App\Models\ServiceDatabase; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; @@ -462,7 +463,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version); } - if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { + if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { $fqdn = "$fqdn$path"; @@ -507,21 +508,15 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations. if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) { $parsed = parseServiceEnvironmentVariable($key->value()); - $normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value(); - $serviceExists = false; - foreach (array_keys($services) as $serviceNameKey) { - if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) { - $serviceExists = true; - break; - } - } - if ($serviceExists) { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]')); - $domainExists = data_get($domains->get($normalizedServiceName), 'domain'); + $normalizedServiceName = normalizeComposeServiceName((string) $parsed['service_name']); + $originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services)); + if ($originalServiceName !== null) { + $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; + $domainExists = getComposeServiceDomainString($domains, $originalServiceName); if (is_null($domainExists)) { $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value(); $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid"); - if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { + if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { $domainValue = "$domainValue$path"; @@ -530,8 +525,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($parsed['port'] && is_numeric($parsed['port'])) { $domainValue = "$domainValue:{$parsed['port']}"; } - $domains->put($normalizedServiceName, ['domain' => $domainValue]); - $resource->docker_compose_domains = $domains->toJson(); + $resource->docker_compose_domains = json_encode(putComposeServiceDomain( + $domains, + $originalServiceName, + $domainValue, + array_keys($services), + )); $resource->save(); } } @@ -568,8 +567,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } $originalServiceName = str($serviceName)->replace('_', '-')->value(); - // Always normalize service names to match docker_compose_domains lookup - $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + // Env var SERVICE_* names still use underscores; domain map keys use original compose names. + $serviceName = normalizeComposeServiceName((string) $serviceName); // Generate BOTH FQDN & URL $fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version); @@ -630,29 +629,24 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } if ($resource->build_pack === 'dockercompose') { - // Check if a service with this name actually exists - $serviceExists = false; - foreach ($services as $serviceNameKey => $service) { - $transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value(); - if ($transformedServiceName === $serviceName) { - $serviceExists = true; - break; - } - } + // Match env-derived name to the real compose service key (hyphens/dots preserved). + $composeServiceName = findComposeServiceName($serviceName, array_keys($services)); // Only add domain if the service exists - if ($serviceExists) { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]')); - $domainExists = data_get($domains->get($serviceName), 'domain'); + if ($composeServiceName !== null) { + $domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []; + $domainExists = getComposeServiceDomainString($domains, $composeServiceName); // Update domain using URL with port if applicable $domainValue = $port ? $urlWithPort : $url; if (is_null($domainExists)) { - $domains->put($serviceName, [ - 'domain' => $domainValue, - ]); - $resource->docker_compose_domains = $domains->toJson(); + $resource->docker_compose_domains = json_encode(putComposeServiceDomain( + $domains, + $composeServiceName, + $domainValue, + array_keys($services), + )); $resource->save(); } } @@ -1186,21 +1180,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($isPullRequest) { $preview = $resource->previews()->find($preview_id); - $domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]); + $domains = collect(json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []); } else { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []); } // Only process domains for dockercompose applications to prevent SERVICE variable recreation if ($resource->build_pack !== 'dockercompose') { $domains = collect([]); } - $changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); - $fqdns = data_get($domains, "$changedServiceName.domain"); + // Prefer original compose service key; fall back to legacy underscore storage keys. + $fqdns = getComposeServiceDomainString($domains, (string) $serviceName); // Generate SERVICE_FQDN & SERVICE_URL for dockercompose if ($resource->build_pack === 'dockercompose') { foreach ($domains as $forServiceName => $domain) { - $parsedDomain = data_get($domain, 'domain'); + $parsedDomain = composeDomainEntryString($domain); $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_'); if (filled($parsedDomain)) { @@ -1209,12 +1203,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $coolifyScheme = $coolifyUrl->getScheme(); $coolifyFqdn = $coolifyUrl->getHost(); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString()); - $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn); + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); + $coolifyEnvironments->put('SERVICE_URL_'.$serviceEnvKey, $coolifyUrl->__toString()); + $coolifyEnvironments->put('SERVICE_FQDN_'.$serviceEnvKey, $coolifyFqdn); $resource->environment_variables()->updateOrCreate([ 'resourceable_type' => Application::class, 'resourceable_id' => $resource->id, - 'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), + 'key' => 'SERVICE_URL_'.$serviceEnvKey, ], [ 'value' => $coolifyUrl->__toString(), 'is_preview' => false, @@ -1222,7 +1217,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $resource->environment_variables()->updateOrCreate([ 'resourceable_type' => Application::class, 'resourceable_id' => $resource->id, - 'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), + 'key' => 'SERVICE_FQDN_'.$serviceEnvKey, ], [ 'value' => $coolifyFqdn, 'is_preview' => false, @@ -1248,9 +1243,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdns = str($fqdns)->explode(','); if ($isPullRequest) { $preview = $resource->previews()->find($preview_id); - $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))); + $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []); if ($docker_compose_domains->count() > 0) { - $found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain"); + $found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName); if ($found_fqdn) { $fqdns = collect($found_fqdn); } else { @@ -1291,15 +1286,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $isDatabase = isDatabaseImage($image, $service); // Add COOLIFY_FQDN & COOLIFY_URL to environment if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { - $fqdnsWithoutPort = $fqdns->map(function ($fqdn) { - return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://')); - }); - $coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(',')); - - $urls = $fqdns->map(function ($fqdn) { - return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':'); - }); - $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(',')); + $coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(',')); + $coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(',')); } add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables); if ($environment->count() > 0) { @@ -1788,8 +1776,10 @@ function serviceParser(Service $resource): Collection $fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version); $url = generateUrl($server, "$fqdnFor-$uuid"); } elseif ($isServiceApplication) { - $fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value(); - $url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value(); + // FQDN may be a comma-separated list; use the first entry (same as updateCompose). + $firstFqdn = firstDomainFromList($savedService->fqdn); + $fqdn = getFqdnWithoutPort($firstFqdn); + $url = $fqdn; } else { // For ServiceDatabase, generate fqdn/url without saving to the model $fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version); @@ -1801,7 +1791,7 @@ function serviceParser(Service $resource): Collection // Strip scheme for environment variable values $fqdnValueForEnv = str($fqdn)->after('://')->value(); - if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { + if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { // Only add path if it's not already present (prevents duplication on subsequent parse() calls) @@ -1905,12 +1895,12 @@ function serviceParser(Service $resource): Collection ->where('key', 'LIKE', $key->value().'_%') ->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$']) ->exists(); - $serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first(); + $serviceExists = findServiceApplicationForEnvName($resource, (string) $fqdnFor); // Check if FQDN already has a port set (contains ':' after the domain) $fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/'); // Only set FQDN if it's for the current service being processed (prevent race conditions) $isCurrentService = $serviceExists && $serviceExists->id === $savedService->id; - if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) { + if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService) { // Save URL otherwise it won't work. $serviceExists->fqdn = $url; $serviceExists->save(); @@ -1950,12 +1940,12 @@ function serviceParser(Service $resource): Collection ->where('key', 'LIKE', $key->value().'_%') ->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$']) ->exists(); - $serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first(); + $serviceExists = findServiceApplicationForEnvName($resource, (string) $urlFor); // Check if FQDN already has a port set (contains ':' after the domain) $fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/'); // Only set FQDN if it's for the current service being processed (prevent race conditions) $isCurrentService = $serviceExists && $serviceExists->id === $savedService->id; - if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) { + if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService) { $serviceExists->fqdn = $url; $serviceExists->save(); } @@ -2561,14 +2551,8 @@ function serviceParser(Service $resource): Collection // Add COOLIFY_FQDN & COOLIFY_URL to environment if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { - $fqdnsWithoutPort = $fqdns->map(function ($fqdn) { - return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':'); - }); - $coolifyEnvironments->put('COOLIFY_FQDN', $fqdnsWithoutPort->implode(',')); - $urls = $fqdns->map(function ($fqdn): Stringable { - return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://')); - }); - $coolifyEnvironments->put('COOLIFY_URL', $urls->implode(',')); + $coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(',')); + $coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(',')); } add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables); if ($environment->count() > 0) { diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 20b184a01..d8986de3f 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -139,7 +139,7 @@ function replaceVariables(string $variable): Stringable function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false) { try { - if ($oneService->getMorphClass() === \App\Models\Application::class) { + if ($oneService->getMorphClass() === Application::class) { $workdir = $oneService->workdir(); $server = $oneService->destination->server; } else { @@ -204,7 +204,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli instant_remote_process(["mkdir -p $fileLocation"], $server); } } - } catch (\Throwable $e) { + } catch (Throwable $e) { return handleError($e); } } @@ -214,7 +214,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) $name = data_get($resource, 'name'); $dockerComposeRaw = data_get($resource, 'service.docker_compose_raw'); if (! $dockerComposeRaw) { - throw new \Exception('No compose file found or not a valid YAML file.'); + throw new Exception('No compose file found or not a valid YAML file.'); } $dockerCompose = Yaml::parse($dockerComposeRaw); @@ -325,22 +325,13 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) } if ($resource->fqdn) { - $resourceFqdns = str($resource->fqdn)->explode(','); - $resourceFqdns = $resourceFqdns->first(); - $url = Url::fromString($resourceFqdns); + $firstFqdn = firstDomainFromList($resource->fqdn); + $url = Url::fromString($firstFqdn); $port = $url->getPort(); - $path = $url->getPath(); - // Prepare URL value (with scheme and host) - $urlValue = $url->getScheme().'://'.$url->getHost(); - $urlValue = ($path === '/') ? $urlValue : $urlValue.$path; - - // Prepare FQDN value (host only, no scheme) - $fqdnHost = $url->getHost(); - $fqdnValue = str($fqdnHost)->after('://'); - if ($path !== '/') { - $fqdnValue = $fqdnValue.$path; - } + // Same helpers as application/service parsers (COOLIFY_URL / COOLIFY_FQDN). + $urlValue = getFqdnWithoutPort($firstFqdn); + $fqdnValue = getHostWithoutPort($firstFqdn); // For each service name found in template, create BOTH SERVICE_URL and SERVICE_FQDN pairs foreach ($serviceNamesToProcess as $serviceInfo) { @@ -396,7 +387,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) } } } - } catch (\Throwable $e) { + } catch (Throwable $e) { return handleError($e); } } @@ -495,7 +486,7 @@ function applyServiceApplicationPrerequisites(Service $service): void } } } - } catch (\Throwable $e) { + } catch (Throwable $e) { // Log error but don't throw - prerequisites are nice-to-have, not critical Log::error('Failed to apply service application prerequisites', [ 'service_id' => $service->id, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8900c0cd3..43c02a99c 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -749,18 +749,71 @@ function base_ip(): string return 'localhost'; } -function getFqdnWithoutPort(string $fqdn) +/** + * Parse a domain URL into scheme/host/path pieces used by COOLIFY_* and SERVICE_* env builders. + * Omits a bare "/" path so port re-append stays valid ("http://host:80" not "http://host/:80"). + * + * @return array{scheme: string, host: string, path: string}|null + */ +function parseDomainUrlParts(string $fqdn): ?array { try { $url = Url::fromString($fqdn); $host = $url->getHost(); - $scheme = $url->getScheme(); - $path = $url->getPath(); + if ($host === '') { + return null; + } - return "$scheme://$host$path"; + $path = $url->getPath(); + if ($path === '' || $path === '/') { + $path = ''; + } + + return [ + 'scheme' => $url->getScheme(), + 'host' => $host, + 'path' => $path, + ]; } catch (Throwable) { + return null; + } +} + +/** + * Absolute URL without port (and without a bare trailing slash). + * Used for COOLIFY_URL / SERVICE_URL base values. + */ +function getFqdnWithoutPort(string $fqdn): string +{ + $parts = parseDomainUrlParts($fqdn); + if ($parts === null || $parts['scheme'] === '') { + // Spatie accepts bare hostnames (empty scheme). Do not invent "://host". return $fqdn; } + + return $parts['scheme'].'://'.$parts['host'].$parts['path']; +} + +/** + * Host (+ optional path) without scheme or port. + * Used for COOLIFY_FQDN / SERVICE_FQDN base values. + */ +function getHostWithoutPort(string $fqdn): string +{ + $parts = parseDomainUrlParts($fqdn); + if ($parts === null) { + return $fqdn; + } + + return $parts['host'].$parts['path']; +} + +/** + * First entry from a comma-separated FQDN list (service apps may store multiple). + */ +function firstDomainFromList(?string $fqdns): string +{ + return trim((string) str($fqdns ?? '')->explode(',')->first()); } /** * If fqdn is set, return it, otherwise return public ip. @@ -2538,7 +2591,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($env) { $env_url = Url::fromString($savedService->fqdn); $env_port = $env_url->getPort(); - if ($env_port !== $predefinedPort) { + if ((int) $env_port !== (int) $predefinedPort) { $env_url = $env_url->withPort($predefinedPort); $savedService->fqdn = $env_url->__toString(); $savedService->save(); @@ -2623,7 +2676,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($env) { $env_url = Url::fromString($env->value); $env_port = $env_url->getPort(); - if ($env_port !== $predefinedPort) { + if ((int) $env_port !== (int) $predefinedPort) { $env_url = $env_url->withPort($predefinedPort); $savedService->fqdn = $env_url->__toString(); $savedService->save(); @@ -3444,16 +3497,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($resource->serviceType()) { $fqdns = generateServiceSpecificFqdns($resource); } else { - $domains = collect(json_decode($resource->docker_compose_domains)) ?? []; + $domains = json_decode($resource->docker_compose_domains ?: '[]', true) ?: []; if ($domains) { - $fqdns = data_get($domains, "$serviceName.domain"); + // Dual-read: original compose name or legacy underscore key. + $fqdns = getComposeServiceDomainString($domains, (string) $serviceName); if ($fqdns) { $fqdns = str($fqdns)->explode(','); if ($pull_request_id !== 0) { $preview = $resource->previews()->find($preview_id); - $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))); - if ($docker_compose_domains->count() > 0) { - $found_fqdn = data_get($docker_compose_domains, "$serviceName.domain"); + $docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []; + if (count($docker_compose_domains) > 0) { + $found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName); if ($found_fqdn) { $fqdns = collect($found_fqdn); } else { diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 00ae2d2ff..2cd3f338c 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -211,6 +211,86 @@ YAML; ->and($domains['backend']['domain'])->toStartWith('http://'); }); +test('applicationParser stores domains under original hyphenated compose service names', function () { + $dockerCompose = <<<'YAML' +services: + another-service: + image: myapp/api:latest + environment: + - SERVICE_FQDN_ANOTHER_SERVICE=${API_URL} + analytics: + image: myapp/analytics:latest +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + + applicationParser($application); + + $application->refresh(); + $domains = json_decode($application->docker_compose_domains, true); + + expect($domains)->toBeArray() + ->and($domains)->toHaveKey('another-service') + ->and($domains)->not->toHaveKey('another_service') + ->and($domains['another-service']['domain'])->toStartWith('http://'); +}); + +test('applicationParser preserves legacy underscore domain keys by matching hyphenated services', function () { + $dockerCompose = <<<'YAML' +services: + another-service: + image: myapp/api:latest + environment: + - SERVICE_FQDN_ANOTHER_SERVICE=${API_URL} +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => json_encode([ + 'another_service' => ['domain' => 'https://legacy.example.com'], + ]), + ]); + + applicationParser($application); + + $application->refresh(); + $domains = json_decode($application->docker_compose_domains, true); + + // Existing domain is preserved (not overwritten) even when stored under legacy underscore key. + expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com'); +}); + +test('compose domain reconciliation preserves stored domains when parsing returns no services', function () { + $storedDomains = json_encode([ + 'frontend' => ['domain' => 'https://frontend.example.com'], + ]); + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => $storedDomains, + ]); + + $method = new ReflectionMethod($application, 'reconcileDockerComposeDomains'); + $method->invoke($application, ['services' => []]); + + expect($application->fresh()->docker_compose_domains)->toBe($storedDomains); +}); + test('applicationParser handles other docker compose domain shapes without regressions', function () { $createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application { return Application::factory()->create([ diff --git a/tests/Feature/ComposePreviewFqdnTest.php b/tests/Feature/ComposePreviewFqdnTest.php index a5b8b2c9f..f49183d8c 100644 --- a/tests/Feature/ComposePreviewFqdnTest.php +++ b/tests/Feature/ComposePreviewFqdnTest.php @@ -2,6 +2,11 @@ use App\Models\Application; use App\Models\ApplicationPreview; +use App\Models\Environment; +use App\Models\Project; +use App\Models\Server; +use App\Models\StandaloneDocker; +use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -57,6 +62,52 @@ it('populates fqdn with multiple domains from multiple services', function () { expect($preview->fqdn)->toContain('api.example.com'); }); +it('preserves distinct services whose normalized names collide when generating preview domains', function () { + $dockerCompose = <<<'YAML' +services: + api-test: + image: nginx:alpine + api.test: + image: nginx:alpine +YAML; + + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $server->id]); + + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'docker_compose_raw' => $dockerCompose, + 'docker_compose_domains' => json_encode([ + 'api-test' => ['domain' => 'https://hyphen.example.com'], + 'api.test' => ['domain' => 'https://dot.example.com'], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 17, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/17', + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + + $preview->generate_preview_fqdn_compose(); + $preview->refresh(); + + $domains = json_decode($preview->docker_compose_domains, true); + + expect($domains)->toHaveKeys(['api-test', 'api.test']) + ->and($domains)->toHaveCount(2) + ->and($domains['api-test']['domain'])->toContain('hyphen.example.com') + ->and($domains['api.test']['domain'])->toContain('dot.example.com'); +}); + it('sets fqdn to null when no domains are configured', function () { $application = Application::factory()->create([ 'build_pack' => 'dockercompose', @@ -78,3 +129,64 @@ it('sets fqdn to null when no domains are configured', function () { expect($preview->fqdn)->toBeNull(); }); + +it('collapses dashed and underscore twin domain keys into one preview service', function () { + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => json_encode([ + 'web-api' => ['domain' => 'https://web-api.example.com'], + 'web_api' => ['domain' => ''], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 58, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/58', + // Existing dual-key preview state from older Coolify versions / mixed write paths. + 'docker_compose_domains' => json_encode([ + 'web_api' => ['domain' => 'https://old-preview.example.com'], + 'web-api' => ['domain' => ''], + ]), + ]); + + $preview->generate_preview_fqdn_compose(); + $preview->refresh(); + + $domains = json_decode($preview->docker_compose_domains, true); + + expect($domains)->toHaveKey('web-api') + ->and($domains)->not->toHaveKey('web_api') + ->and($domains)->toHaveCount(1) + ->and($domains['web-api']['domain'])->toContain('web-api.example.com') + ->and($domains['web-api']['domain'])->toContain('58') + ->and($preview->fqdn)->toContain('web-api.example.com'); +}); + +it('reads legacy underscore application domains when generating previews for dashed services', function () { + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => json_encode([ + 'web_api' => ['domain' => 'https://legacy.example.com'], + ]), + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 12, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/12', + 'docker_compose_domains' => json_encode([ + 'web-api' => ['domain' => ''], + ]), + ]); + + $preview->generate_preview_fqdn_compose(); + $preview->refresh(); + + $domains = json_decode($preview->docker_compose_domains, true); + + expect($domains)->toHaveKey('web-api') + ->and($domains)->not->toHaveKey('web_api') + ->and($domains['web-api']['domain'])->toContain('legacy.example.com') + ->and($preview->fqdn)->toContain('legacy.example.com'); +}); diff --git a/tests/Feature/ServiceDomainPortCorruptionTest.php b/tests/Feature/ServiceDomainPortCorruptionTest.php new file mode 100644 index 000000000..89a4b6bc2 --- /dev/null +++ b/tests/Feature/ServiceDomainPortCorruptionTest.php @@ -0,0 +1,93 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +test('updateCompose keeps valid SERVICE_URL values when domain includes required port', function () { + $template = <<<'YAML' +services: + gitlab: + image: gitlab/gitlab-ce:latest + environment: + - SERVICE_URL_GITLAB_80 + - EXTERNAL_URL=$SERVICE_URL_GITLAB +YAML; + + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => $template, + 'compose_parsing_version' => '5', + ]); + + $serviceApp = ServiceApplication::create([ + 'name' => 'gitlab', + 'service_id' => $service->id, + 'image' => 'gitlab/gitlab-ce:latest', + 'fqdn' => 'http://git.example.com:80', + ]); + + // EditDomain::submit calls updateCompose after saving fqdn + updateCompose($serviceApp); + + $serviceApp->refresh(); + + expect($serviceApp->fqdn)->toBe('http://git.example.com:80') + ->and($serviceApp->fqdn)->not->toContain('//:80') + ->and(fn () => Url::fromString($serviceApp->fqdn))->not->toThrow(Throwable::class); + + $baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_GITLAB')->first(); + $portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_GITLAB_80')->first(); + + expect($baseUrl)->not->toBeNull() + ->and($portUrl)->not->toBeNull() + ->and($baseUrl->value)->toBe('http://git.example.com') + ->and($portUrl->value)->toBe('http://git.example.com:80') + ->and(fn () => Url::fromString($baseUrl->value))->not->toThrow(Throwable::class) + ->and(fn () => Url::fromString($portUrl->value))->not->toThrow(Throwable::class); +}); + +test('getFqdnWithoutPort used by serviceParser strip is safe for ported service domains', function () { + // Mirrors serviceParser when $savedService->fqdn already has a port + $savedFqdn = 'http://git.example.com:80'; + $firstFqdn = trim((string) str($savedFqdn)->explode(',')->first()); + $url = getFqdnWithoutPort($firstFqdn); + $port = '80'; + $urlWithPort = "$url:$port"; + + expect($url)->toBe('http://git.example.com') + ->and($urlWithPort)->toBe('http://git.example.com:80') + ->and($urlWithPort)->not->toBe('http://git.example.com/:80') + ->and(fn () => Url::fromString($urlWithPort))->not->toThrow(Throwable::class); +}); diff --git a/tests/Unit/ApplicationParserStringableTest.php b/tests/Unit/ApplicationParserStringableTest.php index dfb1870db..eaba71984 100644 --- a/tests/Unit/ApplicationParserStringableTest.php +++ b/tests/Unit/ApplicationParserStringableTest.php @@ -1,5 +1,7 @@ toBeTrue('$originalServiceName should be a plain string'); expect(is_string($serviceName))->toBeTrue('$serviceName should be a plain string'); - expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class); - expect($serviceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class); + expect($originalServiceName)->not->toBeInstanceOf(Stringable::class); + expect($serviceName)->not->toBeInstanceOf(Stringable::class); // Verify the transformations work correctly expect($originalServiceName)->toBe('my-service'); @@ -135,7 +137,7 @@ it('ensures originalServiceName conversion works for FQDN generation', function $originalServiceName = str($serviceName)->replace('_', '-')->value(); expect(is_string($originalServiceName))->toBeTrue(); - expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class); + expect($originalServiceName)->not->toBeInstanceOf(Stringable::class); expect($originalServiceName)->toBe('my-service'); // Verify it can be used in string interpolation (line 544) @@ -177,6 +179,9 @@ it('verifies parsers.php has the ->value() calls', function () { // Line 539: Check originalServiceName conversion expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()"); - // Line 541: Check serviceName normalization - expect($parsersFile)->toContain("str(\$serviceName)->replace('-', '_')->replace('.', '_')->value()"); + // Service name normalization for env keys / domain map (helper-based) + expect($parsersFile)->toContain('normalizeComposeServiceName((string) $serviceName)') + ->and($parsersFile)->toContain('getComposeServiceDomainString') + ->and($parsersFile)->toContain('putComposeServiceDomain') + ->and($parsersFile)->toContain('findServiceApplicationForEnvName'); }); diff --git a/tests/Unit/ComposeServiceNameHelpersTest.php b/tests/Unit/ComposeServiceNameHelpersTest.php new file mode 100644 index 000000000..050bea94a --- /dev/null +++ b/tests/Unit/ComposeServiceNameHelpersTest.php @@ -0,0 +1,224 @@ +toBe('another_service') + ->and(normalizeComposeServiceName('api.test'))->toBe('api_test') + ->and(normalizeComposeServiceName('api'))->toBe('api'); +}); + +test('findComposeServiceName resolves legacy underscore keys to original names', function () { + $services = ['another-service', 'api.test', 'api']; + + expect(findComposeServiceName('another-service', $services))->toBe('another-service') + ->and(findComposeServiceName('another_service', $services))->toBe('another-service') + ->and(findComposeServiceName('api.test', $services))->toBe('api.test') + ->and(findComposeServiceName('api_test', $services))->toBe('api.test') + ->and(findComposeServiceName('missing', $services))->toBeNull(); +}); + +test('getComposeServiceDomainString reads original and legacy keys without dotted data_get bugs', function () { + $domains = [ + 'api.test' => ['domain' => 'https://dotted.example.com'], + 'another_service' => ['domain' => 'https://legacy.example.com'], + ]; + + expect(getComposeServiceDomainString($domains, 'api.test'))->toBe('https://dotted.example.com') + ->and(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com') + ->and(getComposeServiceDomainString($domains, 'missing'))->toBeNull(); +}); + +test('putComposeServiceDomain writes original service keys and drops twin underscore keys', function () { + $domains = putComposeServiceDomain( + ['another_service' => ['domain' => 'https://old.example.com']], + 'another-service', + 'https://new.example.com', + ['another-service', 'analytics'], + ); + + expect($domains)->toHaveKey('another-service') + ->and($domains)->not->toHaveKey('another_service') + ->and($domains['another-service']['domain'])->toBe('https://new.example.com'); +}); + +test('rekeyComposeDomainsToServiceNames migrates underscore keys to original compose names', function () { + $rekeyed = rekeyComposeDomainsToServiceNames( + [ + 'another_service' => ['domain' => 'https://hyphen.example.com'], + 'api_test' => ['domain' => 'https://dotted.example.com'], + 'orphan' => ['domain' => 'https://orphan.example.com'], + ], + ['another-service', 'api.test'], + ); + + expect($rekeyed)->toHaveKey('another-service') + ->and($rekeyed)->not->toHaveKey('another_service') + ->and($rekeyed['another-service']['domain'])->toBe('https://hyphen.example.com') + ->and($rekeyed)->toHaveKey('api.test') + ->and($rekeyed['api.test']['domain'])->toBe('https://dotted.example.com') + ->and($rekeyed)->toHaveKey('orphan'); +}); + +test('rekeyComposeDomainsToServiceNames prefers the canonical domain regardless of twin key order', function (array $domains) { + $rekeyed = rekeyComposeDomainsToServiceNames($domains, ['another-service']); + + expect($rekeyed['another-service']['domain'])->toBe('https://canonical.example.com'); +})->with([ + 'legacy key first' => [[ + 'another_service' => ['domain' => 'https://legacy.example.com'], + 'another-service' => ['domain' => 'https://canonical.example.com'], + ]], + 'canonical key first' => [[ + 'another-service' => ['domain' => 'https://canonical.example.com'], + 'another_service' => ['domain' => 'https://legacy.example.com'], + ]], +]); + +test('rekeyComposeDomainsToServiceNames never lets empty canonical wipe filled legacy', function (array $domains) { + $rekeyed = rekeyComposeDomainsToServiceNames($domains, ['another-service']); + + expect($rekeyed)->toHaveKey('another-service') + ->and($rekeyed)->not->toHaveKey('another_service') + ->and($rekeyed['another-service']['domain'])->toBe('https://legacy.example.com'); +})->with([ + 'empty canonical first' => [[ + 'another-service' => ['domain' => ''], + 'another_service' => ['domain' => 'https://legacy.example.com'], + ]], + 'filled legacy first' => [[ + 'another_service' => ['domain' => 'https://legacy.example.com'], + 'another-service' => ['domain' => ''], + ]], +]); + +test('getComposeServiceDomainString prefers filled twin over blank original key', function () { + $domains = [ + 'another-service' => ['domain' => ''], + 'another_service' => ['domain' => 'https://legacy.example.com'], + ]; + + expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com') + ->and(getComposeServiceDomainString($domains, 'another_service'))->toBe('https://legacy.example.com'); +}); + +test('preferredComposeServiceNamesFromDomainKeys collapses underscore twins', function () { + expect(preferredComposeServiceNamesFromDomainKeys(['web_api', 'web-api', 'api']))->toEqualCanonicalizing(['web-api', 'api']); +}); + +test('legacy underscore domain keys still resolve for hyphenated compose services', function () { + // Existing production shape: domains stored under underscore keys only. + $legacy = [ + 'another_service' => ['domain' => 'https://legacy.example.com'], + 'web' => ['domain' => 'https://web.example.com'], + ]; + + expect(getComposeServiceDomainString($legacy, 'another-service'))->toBe('https://legacy.example.com') + ->and(getComposeServiceDomainString($legacy, 'another_service'))->toBe('https://legacy.example.com') + ->and(getComposeServiceDomainString($legacy, 'web'))->toBe('https://web.example.com'); +}); + +test('SERVICE env keys stay underscore-normalized for both storage shapes', function () { + // Mirrors ApplicationDeploymentJob: domain map key may be original or legacy. + foreach (['another-service', 'another_service'] as $storageKey) { + $envKey = str(normalizeComposeServiceName($storageKey))->upper()->toString(); + expect($envKey)->toBe('ANOTHER_SERVICE') + ->and('SERVICE_URL_'.$envKey)->toBe('SERVICE_URL_ANOTHER_SERVICE'); + } +}); + +test('findComposeServiceName maps SERVICE env fragments used by serviceParser magic path', function () { + // serviceParser historically only did underscore→hyphen, so API_TEST became name "api-test" + // and missed compose service "api.test". normalize-based lookup finds the real name. + $services = ['another-service', 'api.test', 'web']; + + expect(findComposeServiceName('another_service', $services))->toBe('another-service') + ->and(findComposeServiceName('api_test', $services))->toBe('api.test') + ->and(findComposeServiceName('api-test', $services))->toBe('api.test') // same normalized form + ->and(findComposeServiceName('web', $services))->toBe('web'); + + // Exact DB lookup the old serviceParser used would miss dotted names: + expect(in_array('api-test', $services, true))->toBeFalse() + ->and(in_array('api.test', $services, true))->toBeTrue(); +}); + +test('normalized service name collisions only resolve exact matches', function () { + $services = ['api-test', 'api.test']; + + expect(findComposeServiceName('api-test', $services))->toBe('api-test') + ->and(findComposeServiceName('api.test', $services))->toBe('api.test') + ->and(findComposeServiceName('api_test', $services))->toBeNull(); +}); + +test('rekeyComposeDomainsToServiceNames keeps ambiguous normalized keys separate', function () { + $rekeyed = rekeyComposeDomainsToServiceNames( + [ + 'api-test' => ['domain' => 'https://hyphen.example.com'], + 'api.test' => ['domain' => 'https://dot.example.com'], + 'api_test' => ['domain' => 'https://legacy.example.com'], + ], + ['api-test', 'api.test'], + ); + + expect($rekeyed['api-test']['domain'])->toBe('https://hyphen.example.com') + ->and($rekeyed['api.test']['domain'])->toBe('https://dot.example.com') + ->and($rekeyed['api_test']['domain'])->toBe('https://legacy.example.com'); +}); + +test('empty parsed compose services do not wipe existing domains', function () { + $application = new Application; + $application->docker_compose_domains = json_encode([ + 'web' => ['domain' => 'https://web.example.com'], + ]); + + $method = new ReflectionMethod($application, 'reconcileDockerComposeDomains'); + $method->invoke($application, collect(['services' => []])); + + expect($application->docker_compose_domains)->toBe(json_encode([ + 'web' => ['domain' => 'https://web.example.com'], + ])); +}); + +test('normalized service collisions retain domains under their exact compose keys', function () { + $domains = collect([ + 'api-test' => ['domain' => 'https://hyphen.example.com'], + 'api.test' => ['domain' => 'https://dot.example.com'], + ]); + + $rekeyed = rekeyComposeDomainsToServiceNames($domains, collect(['api-test', 'api.test'])); + + expect($rekeyed)->toBe([ + 'api-test' => ['domain' => 'https://hyphen.example.com'], + 'api.test' => ['domain' => 'https://dot.example.com'], + ]); +}); + +test('General rekeys form domains to original compose service names', function (array|Collection $domains) { + $component = new General; + $component->parsedServices = collect([ + 'services' => collect([ + 'web-api' => [], + 'metrics.internal' => [], + ]), + ]); + $component->parsedServiceDomains = $domains; + + $method = new ReflectionMethod($component, 'composeDomainsForStorage'); + $stored = $method->invoke($component); + + expect($stored)->toBe([ + 'web-api' => ['domain' => 'https://api.example.com'], + 'metrics.internal' => ['domain' => 'https://metrics.example.com'], + ]); +})->with([ + 'array' => [[ + 'web_api' => ['domain' => 'https://api.example.com'], + 'metrics_internal' => ['domain' => 'https://metrics.example.com'], + ]], + 'collection' => [collect([ + 'web_api' => ['domain' => 'https://api.example.com'], + 'metrics_internal' => ['domain' => 'https://metrics.example.com'], + ])], +]); diff --git a/tests/Unit/DomainUrlHelpersTest.php b/tests/Unit/DomainUrlHelpersTest.php new file mode 100644 index 000000000..1acc7f464 --- /dev/null +++ b/tests/Unit/DomainUrlHelpersTest.php @@ -0,0 +1,93 @@ +toBe('git.example.com') + ->and(getHostWithoutPort('https://n8n.example.com:5678'))->toBe('n8n.example.com') + ->and(getHostWithoutPort('http://git.example.com'))->toBe('git.example.com'); +}); + +test('getHostWithoutPort preserves path segments', function () { + expect(getHostWithoutPort('http://git.example.com:80/v1/realtime')) + ->toBe('git.example.com/v1/realtime'); +}); + +test('getHostWithoutPort keeps real host when credentials are present', function () { + // Legacy COOLIFY_FQDN strip: replace scheme then before(':') → "user" + expect(getHostWithoutPort('http://user:secret@git.example.com:80')) + ->toBe('git.example.com'); +}); + +test('getHostWithoutPort handles scheme-less host:port', function () { + expect(getHostWithoutPort('git.example.com:80'))->toBe('git.example.com'); +}); + +test('getHostWithoutPort returns original when host cannot be parsed', function () { + expect(getHostWithoutPort('not-a-url'))->toBe('not-a-url'); +}); + +test('legacy COOLIFY_FQDN strip corrupts credential urls (documents why we refactor)', function () { + $fqdn = 'http://user:secret@git.example.com:80'; + + $legacy = (string) str($fqdn) + ->replace('http://', '') + ->replace('https://', '') + ->before(':'); + + expect($legacy)->toBe('user') + ->and($legacy)->not->toBe(getHostWithoutPort($fqdn)); +}); + +// --------------------------------------------------------------------------- +// firstDomainFromList +// --------------------------------------------------------------------------- + +test('firstDomainFromList returns the first comma-separated domain trimmed', function () { + expect(firstDomainFromList('http://a.example.com:80,http://b.example.com:80')) + ->toBe('http://a.example.com:80') + ->and(firstDomainFromList(' https://only.example.com ')) + ->toBe('https://only.example.com') + ->and(firstDomainFromList(null)) + ->toBe('') + ->and(firstDomainFromList('')) + ->toBe(''); +}); + +// --------------------------------------------------------------------------- +// updateCompose / parser pairing: URL + FQDN from the same helpers +// --------------------------------------------------------------------------- + +test('url and host helpers stay paired for ported service domains', function () { + $fqdn = 'http://git.example.com:80'; + + $urlValue = getFqdnWithoutPort($fqdn); + $fqdnValue = getHostWithoutPort($fqdn); + $port = '80'; + + expect($urlValue)->toBe('http://git.example.com') + ->and($fqdnValue)->toBe('git.example.com') + ->and($urlValue.':'.$port)->toBe('http://git.example.com:80') + ->and($fqdnValue.':'.$port)->toBe('git.example.com:80') + ->and($urlValue.':'.$port)->not->toContain('/:'); +}); + +test('first domain then helpers match multi-domain service FQDN handling', function () { + $saved = 'http://git.example.com:80,http://git-alt.example.com:80'; + $first = firstDomainFromList($saved); + + expect($first)->toBe('http://git.example.com:80') + ->and(getFqdnWithoutPort($first))->toBe('http://git.example.com') + ->and(getHostWithoutPort($first))->toBe('git.example.com'); +}); diff --git a/tests/Unit/GetFqdnWithoutPortTest.php b/tests/Unit/GetFqdnWithoutPortTest.php new file mode 100644 index 000000000..6fa5e410c --- /dev/null +++ b/tests/Unit/GetFqdnWithoutPortTest.php @@ -0,0 +1,51 @@ +after('://')->before(':') stripping corrupts URLs that contain + * credentials, IPv6, or other non-trivial structure. getFqdnWithoutPort() must + * strip only the port and keep a usable base URL for COOLIFY_URL / SERVICE_URL. + */ +test('getFqdnWithoutPort strips port from simple http urls without trailing slash', function () { + expect(getFqdnWithoutPort('http://git.example.com:80'))->toBe('http://git.example.com') + ->and(getFqdnWithoutPort('https://n8n.example.com:5678'))->toBe('https://n8n.example.com') + ->and(getFqdnWithoutPort('http://git.example.com'))->toBe('http://git.example.com'); +}); + +test('getFqdnWithoutPort preserves path segments when stripping port', function () { + expect(getFqdnWithoutPort('http://git.example.com:80/v1/realtime')) + ->toBe('http://git.example.com/v1/realtime'); +}); + +test('getFqdnWithoutPort keeps host when credentials are present', function () { + // Fragile strip would turn this into "http://user" — helper must not. + expect(getFqdnWithoutPort('http://user:secret@git.example.com:80')) + ->toBe('http://git.example.com'); +}); + +test('getFqdnWithoutPort returns original string when input is not a valid url', function () { + expect(getFqdnWithoutPort('not-a-url'))->toBe('not-a-url'); +}); + +test('fragile after/before strip corrupts credential urls (documents #8798 class of bugs)', function () { + $fqdn = 'http://user:secret@git.example.com:80'; + + $fragile = (string) str($fqdn) + ->after('://') + ->before(':') + ->prepend(str($fqdn)->before('://')->append('://')); + + expect($fragile)->toBe('http://user') + ->and($fragile)->not->toBe(getFqdnWithoutPort($fqdn)); +}); + +test('port comparisons must cast so string template ports match int url ports', function () { + $envPort = Url::fromString('http://git.example.com:80')->getPort(); + $predefinedPort = '80'; // yaml / data_get style + + expect($envPort !== $predefinedPort)->toBeTrue('strict compare wrongly treats matching ports as different') + ->and((int) $envPort !== (int) $predefinedPort)->toBeFalse(); +}); diff --git a/tests/Unit/TraefikServiceNameSegmentTest.php b/tests/Unit/TraefikServiceNameSegmentTest.php new file mode 100644 index 000000000..60dd1f153 --- /dev/null +++ b/tests/Unit/TraefikServiceNameSegmentTest.php @@ -0,0 +1,97 @@ +toBe(substr(md5('api.test'), 0, 4)) + ->and(traefikServiceNameHash('api.test'))->toHaveLength(4) + ->and(traefikServiceNameHash('api.test'))->not->toBe(traefikServiceNameHash('api-test')); +}); + +test('traefikSafeServiceNameSegment replaces dots and always appends hash', function () { + $segment = traefikSafeServiceNameSegment('api.test'); + $hash = traefikServiceNameHash('api.test'); + + expect($segment)->toBe("api-test-{$hash}") + ->and($segment)->not->toContain('.') + ->and(str_ends_with($segment, "-{$hash}"))->toBeTrue(); +}); + +test('traefikSafeServiceNameSegment keeps api.test and api-test distinct', function () { + $dot = traefikSafeServiceNameSegment('api.test'); + $hyphen = traefikSafeServiceNameSegment('api-test'); + + expect($dot)->not->toBe($hyphen) + ->and($dot)->toStartWith('api-test-') + ->and($hyphen)->toStartWith('api-test-'); +}); + +test('traefikSafeServiceNameSegment normalizes underscores and odd characters', function () { + $segment = traefikSafeServiceNameSegment('web_api.v2'); + $hash = traefikServiceNameHash('web_api.v2'); + + expect($segment)->toBe("web-api-v2-{$hash}") + ->and($segment)->not->toContain('.') + ->and($segment)->not->toContain('_'); +}); + +test('fqdnLabelsForTraefik embeds safe hashed service segment without dotted router names', function () { + $uuid = 'testuuid1234'; + $hashDot = traefikServiceNameHash('api.test'); + $hashHyphen = traefikServiceNameHash('api-test'); + + $dotLabels = fqdnLabelsForTraefik( + uuid: $uuid, + domains: collect(['https://dot.example.com']), + is_force_https_enabled: true, + service_name: 'api.test', + image: 'nginx:alpine', + ); + + $hyphenLabels = fqdnLabelsForTraefik( + uuid: $uuid, + domains: collect(['https://hyphen.example.com']), + is_force_https_enabled: true, + service_name: 'api-test', + image: 'nginx:alpine', + ); + + $dotHttps = $dotLabels->first( + fn (string $line) => str_contains($line, "traefik.http.routers.https-0-{$uuid}-api-test-{$hashDot}.rule=") + ); + $hyphenHttps = $hyphenLabels->first( + fn (string $line) => str_contains($line, "traefik.http.routers.https-0-{$uuid}-api-test-{$hashHyphen}.rule=") + ); + + expect($dotHttps)->not->toBeNull() + ->and($hyphenHttps)->not->toBeNull() + ->and($dotHttps)->not->toContain('api.test') + ->and($dotHttps)->not->toBe($hyphenHttps) + ->and($hashDot)->not->toBe($hashHyphen); + + $ruleLines = $dotLabels->merge($hyphenLabels)->filter( + fn (string $line) => str_contains($line, 'traefik.http.routers.') && str_contains($line, '.rule=') + ); + + // Traefik label path must stay at traefik.http.routers.{name}.rule (5 segments). + foreach ($ruleLines as $line) { + $key = str($line)->before('=')->toString(); + expect(explode('.', $key))->toHaveCount(5); + } +}); + +test('fqdnLabelsForTraefik hyphenated services also receive a hash suffix', function () { + $uuid = 'appuuid'; + $hash = traefikServiceNameHash('another-service'); + + $labels = fqdnLabelsForTraefik( + uuid: $uuid, + domains: collect(['https://another.example.com']), + service_name: 'another-service', + image: 'nginx:alpine', + ); + + $routerLine = $labels->first( + fn (string $line) => str_contains($line, 'traefik.http.routers.https-0-') && str_contains($line, '.rule=') + ); + + expect($routerLine)->toContain("https-0-{$uuid}-another-service-{$hash}.rule="); +});