diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 62153d175..80eb9bf0a 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -554,13 +554,6 @@ protected function effectiveDomainInternalPort(string $url, ?string $service = n ]; } - if ($this->application->settings?->is_static) { - return [ - 'internal_port' => 80, - 'has_port_override' => false, - ]; - } - $composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service); if ($composePort !== null) { return [ @@ -569,13 +562,20 @@ protected function effectiveDomainInternalPort(string $url, ?string $service = n ]; } - if ($this->isCompose && $service !== null && count($this->composeServices) > 1) { + if ($this->isCompose && $service !== null) { return [ 'internal_port' => null, 'has_port_override' => false, ]; } + if ($this->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + $exposed = $this->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] @@ -613,7 +613,7 @@ protected function currentRowPort(string $url): ?int return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; } - protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + protected function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool { if ($this->forceUseUnknownPort || $port === null) { return false; @@ -622,7 +622,7 @@ protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool return false; } - return $this->application->portRequiresConfirmation($port); + return $this->application->portRequiresConfirmation($port, $serviceName); } protected function openPortWarning(?int $port, string $action): void @@ -1018,7 +1018,7 @@ public function addDomain(): void } } - if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) { $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); return; @@ -1415,7 +1415,7 @@ public function updateDomain(): void return; } - if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $service)) { $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); return; diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 66eb2f5e0..2f0ee91ab 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -74,7 +74,7 @@ public function addDomain(): void return; } - if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) { $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); return; @@ -173,7 +173,7 @@ public function updateDomain(): void return; } $oldUrl = $this->domainRows[$this->editingIndex]['url']; - if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) { $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); return; @@ -478,7 +478,7 @@ private function currentRowPort(string $url): ?int return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; } - private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + private function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool { if ($this->forceUseUnknownPort || $port === null) { return false; @@ -487,7 +487,7 @@ private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool return false; } - return $this->preview->application->portRequiresConfirmation($port); + return $this->preview->application->portRequiresConfirmation($port, $serviceName); } private function openPortWarning(?int $port, string $action): void @@ -522,13 +522,6 @@ private function effectiveDomainInternalPort(string $url, ?string $service = nul ]; } - if ($this->preview->application->settings?->is_static) { - return [ - 'internal_port' => 80, - 'has_port_override' => false, - ]; - } - $composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service); if ($composePort !== null) { return [ @@ -537,13 +530,20 @@ private function effectiveDomainInternalPort(string $url, ?string $service = nul ]; } - if ($this->preview->application->build_pack === 'dockercompose' && $service !== null && count($this->composeServices()) > 1) { + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) { return [ 'internal_port' => null, 'has_port_override' => false, ]; } + if ($this->preview->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + $exposed = $this->preview->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] diff --git a/app/Models/Application.php b/app/Models/Application.php index 2ac352149..cec8d501a 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -964,12 +964,16 @@ public function main_port() } /** - * Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains. + * Ports declared by the selected Compose service, or exposed and previously used application ports. * * @return list */ - public function availableInternalPorts(): array + public function availableInternalPorts(?string $serviceName = null): array { + if ($this->build_pack === 'dockercompose') { + return dockerComposeServicePorts($this->docker_compose_raw, $serviceName); + } + $ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array) ->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0) ->map(fn (mixed $port): int => (int) $port); @@ -994,13 +998,13 @@ public function availableInternalPorts(): array return $ports->unique()->sort()->values()->all(); } - public function portRequiresConfirmation(?int $port): bool + public function portRequiresConfirmation(?int $port, ?string $serviceName = null): bool { if ($port === null || $port <= 0) { return false; } - return ! in_array($port, $this->availableInternalPorts(), true); + return ! in_array($port, $this->availableInternalPorts($serviceName), true); } public function detectPortFromEnvironment(?bool $isPreview = false): ?int diff --git a/app/Models/Service.php b/app/Models/Service.php index 1e6a33ad6..3b80a1941 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1477,7 +1477,10 @@ public function getRequiredPort(): ?int { try { $services = get_service_templates(); - $serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value(); + if (blank($this->service_type)) { + return null; + } + $serviceName = $this->service_type; $service = data_get($services, $serviceName, []); $port = data_get($service, 'port'); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index cf0faef5b..cba18c1f2 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -201,7 +201,7 @@ public function fqdns(): Attribute } /** - * Return the public URLs with their persisted internal port overrides. + * Return editable URLs with persisted overrides or legacy embedded ports. */ protected function url(): Attribute { @@ -220,7 +220,7 @@ protected function url(): Attribute $port = $overrides[$canonical] ?? null; if ($port === null) { - return $canonical; + return $url; } $parts = DomainUrlParts::split($canonical); @@ -366,7 +366,7 @@ public function getRequiredPort(): ?int } $dockerCompose = Yaml::parse($dockerComposeRaw); - $serviceConfig = data_get($dockerCompose, "services.{$this->name}"); + $serviceConfig = $dockerCompose['services'][$this->name] ?? null; if (! $serviceConfig) { return $this->service->getRequiredPort(); } @@ -417,9 +417,21 @@ public function getRequiredPort(): ?int return $portFound; } + $composePort = firstDockerComposeServicePort($serviceConfig); + if ($composePort !== null) { + return $composePort; + } + // HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT // suffix), such as WordPress, inherit the one-click template `# port:`. if ($declaresHttpUrl) { + if (blank($this->service->service_type)) { + $savedPort = $this->getSavedLegacyRoutingPort($serviceConfig); + if ($savedPort !== null) { + return $savedPort; + } + } + return $this->service->getRequiredPort(); } @@ -428,4 +440,52 @@ public function getRequiredPort(): ?int return null; } } + + /** + * Preserve only an unambiguous upstream from this legacy container's saved labels. + */ + private function getSavedLegacyRoutingPort(array $serviceConfig): ?int + { + $savedCompose = Yaml::parse($this->service->docker_compose ?? ''); + $savedService = $savedCompose['services'][$this->name] ?? null; + $image = $serviceConfig['image'] ?? null; + if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) { + return null; + } + + $labels = $savedService['labels'] ?? []; + if (! is_array($labels)) { + return null; + } + + $ports = []; + foreach ($labels as $key => $value) { + if (is_int($key)) { + if (! is_string($value)) { + return null; + } + [$key, $value] = array_pad(explode('=', $value, 2), 2, null); + } + + if (preg_match('/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/', $key)) { + $port = $value; + } elseif (preg_match('/^caddy(?:_\d+)?\..*reverse_proxy$/', $key)) { + if (! is_string($value) || ! preg_match('/^\{\{upstreams ([0-9]+)\}\}$/', $value, $matches)) { + return null; + } + $port = $matches[1]; + } else { + continue; + } + + if ((! is_string($port) && ! is_int($port)) || ! preg_match('/^[0-9]+$/', (string) $port) || (int) $port < 1 || (int) $port > 65535) { + return null; + } + $ports[] = (int) $port; + } + + $ports = array_values(array_unique($ports)); + + return count($ports) === 1 ? $ports[0] : null; + } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 84232a8d7..613a104e0 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -607,6 +607,13 @@ function firstDockerComposeServicePort(mixed $service): ?int ->merge(data_get($service, 'ports', [])); foreach ($portDefinitions as $definition) { + $protocol = is_array($definition) + ? data_get($definition, 'protocol', 'tcp') + : (str_contains((string) $definition, '/') ? str((string) $definition)->afterLast('/')->value() : 'tcp'); + if ($protocol !== 'tcp') { + continue; + } + $port = is_array($definition) ? data_get($definition, 'target') : str((string) $definition)->before('/')->afterLast(':')->value(); @@ -620,18 +627,29 @@ function firstDockerComposeServicePort(mixed $service): ?int } function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int +{ + return dockerComposeServicePorts($compose, $serviceName)[0] ?? null; +} + +function dockerComposeServicePorts(?string $compose, ?string $serviceName): array { if (blank($compose) || blank($serviceName)) { - return null; + return []; } try { $services = data_get(Yaml::parse($compose), 'services', []); } catch (Throwable) { - return null; + return []; } - return firstDockerComposeServicePort(is_array($services) ? ($services[$serviceName] ?? null) : null); + $service = is_array($services) ? ($services[$serviceName] ?? []) : []; + + return collect(data_get($service, 'expose', [])) + ->merge(data_get($service, 'ports', [])) + ->map(fn ($definition) => firstDockerComposeServicePort(['expose' => [$definition]])) + ->filter(fn ($port) => $port !== null) + ->unique()->values()->all(); } function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index dd8ea29c8..e50b852ec 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -390,9 +390,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int return collect([]); } $services = data_get($yaml, 'services', collect([])); - $applicationServiceCount = collect($services) - ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) - ->count(); $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), 'networks' => collect(data_get($yaml, 'networks', [])), @@ -1357,9 +1354,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $domainPortOverrides = $isPullRequest ? ($previewForPorts?->domain_port_overrides ?? []) : ($originalResource->domain_port_overrides ?? []); - $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) - ?? ($applicationServiceCount === 1 ? ($exposedPorts[0] ?? null) : null); + $onlyPort = firstDockerComposeServicePort($service); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -1566,7 +1561,6 @@ function serviceParser(Service $resource): Collection $envComments = extractYamlEnvironmentComments($compose); $server = data_get($resource, 'server'); - $allServices = get_service_templates(); try { $yaml = Yaml::parse($compose); @@ -1699,22 +1693,7 @@ function serviceParser(Service $resource): Collection $containerName = "$serviceName-{$resource->uuid}"; - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } + $predefinedPort = $resource->getRequiredPort(); if ($migratedApp || $migratedDb) { // Use the already determined migrated service @@ -2084,22 +2063,7 @@ function serviceParser(Service $resource): Collection $containerName = "$serviceName-{$resource->uuid}"; - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } + $predefinedPort = $resource->getRequiredPort(); if ($migratedApp || $migratedDb) { // Use the already determined migrated service @@ -2642,7 +2606,7 @@ function serviceParser(Service $resource): Collection ? data_get($originalResource, 'redirect') : 'both'; $onlyPort = $originalResource instanceof ServiceApplication - ? ($originalResource->getRequiredPort() ?? $predefinedPort) + ? $originalResource->getRequiredPort() : $predefinedPort; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); @@ -2677,7 +2641,7 @@ function serviceParser(Service $resource): Collection service_name: $serviceName, image: $image, onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection @@ -2710,7 +2674,7 @@ function serviceParser(Service $resource): Collection service_name: $serviceName, image: $image, onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f5db32e88..fbfbc9b56 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2486,7 +2486,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } catch (Exception $e) { throw new RuntimeException($e->getMessage()); } - $allServices = get_service_templates(); $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); $topLevelNetworks = collect(data_get($yaml, 'networks', [])); $topLevelConfigs = collect(data_get($yaml, 'configs', [])); @@ -2512,25 +2511,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $topLevelVolumes = collect($tempTopLevelVolumes); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) { - // Workarounds for beta users. - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } - // End of workarounds for beta users. + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $envComments) { + $predefinedPort = $resource->getRequiredPort(); $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3107,7 +3089,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal ? ($savedService->domain_port_overrides ?? []) : []; $onlyPort = $savedService instanceof ServiceApplication - ? ($savedService->getRequiredPort() ?? $predefinedPort) + ? $savedService->getRequiredPort() : $predefinedPort; if ($shouldGenerateLabelsExactly) { switch ($resource->server->proxyType()) { @@ -3139,7 +3121,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal service_name: $serviceName, image: data_get($service, 'image'), onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, @@ -3172,7 +3154,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal service_name: $serviceName, image: data_get($service, 'image'), onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, @@ -3318,10 +3300,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id !== 0) { $definedNetwork = collect(["{$resource->uuid}-$pull_request_id"]); } - $usesSharedApplicationPort = collect($services) - ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) - ->count() === 1; - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id, $usesSharedApplicationPort) { + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_id) { $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3918,9 +3897,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $domainPortOverrides = $pull_request_id === 0 ? ($resource->domain_port_overrides ?? []) : ($preview?->domain_port_overrides ?? []); - $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) - ?? ($usesSharedApplicationPort ? ($exposedPorts[0] ?? null) : null); + $onlyPort = firstDockerComposeServicePort($service); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 1cc6d05e1..96b044688 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2524,7 +2524,7 @@ 'build_pack' => 'dockercompose', 'fqdn' => null, 'ports_exposes' => '3000,8080', - 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: node:alpine\n", + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [8080]\n api:\n image: node:alpine\n", 'docker_compose_domains' => json_encode([ 'api' => ['domain' => 'https://api.example.com', 'redirect' => 'both'], ]), @@ -2773,3 +2773,117 @@ function applicationDomainPortOverrideApiToken(User $user, Team $team): string ->assertSet('showPortWarningModal', false) ->assertDispatched('success'); }); + +it('does not inherit the application port for single-service compose domains', function (string $extraService, bool $isPreview, bool $isStatic) { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n web:\n image: httpd:2.4-alpine\n".$extraService, + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://apache.example.com'], + ]), + 'fqdn' => null, + 'domain_port_overrides' => null, + ]); + + $this->application->settings()->update(['is_static' => $isStatic]); + + if ($isPreview) { + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 1, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/1', + 'docker_compose_domains' => $this->application->docker_compose_domains, + ]); + $component = Livewire::test(PreviewDomains::class, ['preview' => $preview]); + } else { + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + } + + $component->assertSet('domainRows.0.internal_port', null) + ->assertSet('domainRows.0.has_port_override', false) + ->assertDontSee('Internal port 3000'); +})->with([ + 'web only' => '', + 'web and database' => " database:\n image: postgres:16-alpine\n", +])->with([false, true])->with([false, true]); + +it('checks compose ports against the selected service when saving domains', function (string $action, int $port, bool $warn) { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => <<<'YAML' +services: + web: + image: nginx:alpine + expose: + - 8080 + - 8081 + ports: + - target: 8082 + published: 18082 + - "18083:8083" + api: + image: nginx:alpine + expose: + - 9090 +YAML, + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://existing.example.com'], + 'api' => ['domain' => 'https://api.example.com:9090'], + ]), + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + if ($action === 'add') { + $component->set('newDomainService', 'web') + ->set('newDomainParts.host', 'new.example.com') + ->set('newDomainParts.port', (string) $port) + ->call('addDomain'); + $domain = 'https://new.example.com'; + } else { + $component->call('startEdit', 0) + ->set('editingDomainParts.port', (string) $port) + ->call('updateDomain'); + $domain = 'https://existing.example.com'; + } + + $component->assertHasNoErrors()->assertSet('showPortWarningModal', $warn); + + if ($warn) { + expect($this->application->fresh()->domain_port_overrides ?? [])->not->toHaveKey($domain); + $component->assertSet('unrecognizedPort', $port) + ->call('confirmUseUnknownPort') + ->assertSet('showPortWarningModal', false); + } + + $component->assertDispatched('success'); + expect($this->application->fresh()->domain_port_overrides[$domain] ?? null)->toBe($port); +})->with(['add', 'edit'])->with([ + 'first exposed port' => [8080, false], + 'second exposed port' => [8081, false], + 'long syntax target' => [8082, false], + 'short syntax target' => [8083, false], + 'global port' => [3000, true], + 'another service port' => [9090, true], + 'published host port' => [18082, true], +]); + +it('keeps an existing custom compose port without another warning', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [8080]\n", + 'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://existing.example.com:7070']]), + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->assertSet('editingDomainParts.port', '7070') + ->call('updateDomain') + ->assertHasNoErrors() + ->assertSet('showPortWarningModal', false) + ->assertDispatched('success'); + + expect($this->application->fresh()->domain_port_overrides['https://existing.example.com'] ?? null)->toBe(7070); +}); diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index c20778202..10e312b54 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -1,6 +1,7 @@ toBeTrue(); }); -test('applicationParser compose labels use the first ports_exposes value when a portless domain has no override', function () { +test('single-service compose leaves undeclared ports to proxy discovery', function (int $parserVersion, string $extraService, bool $isPreview) { $application = disableExactProxyLabels(Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => StandaloneDocker::class, 'build_pack' => 'dockercompose', + 'compose_parsing_version' => $parserVersion, 'ports_exposes' => '3000,8080', - 'docker_compose_raw' => <<<'YAML' + 'docker_compose_raw' => << null, 'domain_port_overrides' => null, @@ -589,16 +592,29 @@ function disableExactProxyLabels(Application $application): Application ]), ])); - $parsedCompose = applicationParser($application->fresh()); - $labels = collect(data_get($parsedCompose, 'services.frontend.labels')); + $preview = $isPreview ? ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 1, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/1', + 'docker_compose_domains' => $application->docker_compose_domains, + ]) : null; - expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=3000'))) - ->toBeTrue() - ->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 3000}}'))) + $parsedCompose = $application->fresh()->parse( + pull_request_id: $preview?->pull_request_id ?? 0, + preview_id: $preview?->id, + ); + $labels = collect(data_get($parsedCompose, 'services'))->flatMap(fn ($service) => data_get($service, 'labels', [])); + + expect($labels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) + ->toBeFalse() + ->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams}}'))) ->toBeTrue() ->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`frontend.example.com`)'))) ->toBeTrue(); -}); +})->with([2, 3])->with([ + 'web only' => '', + 'web and database' => " database:\n image: postgres:16-alpine", +])->with([false, true]); test('applicationParser compose labels prefer the service exposed port over application ports_exposes', function (string $portConfiguration) { $application = disableExactProxyLabels(Application::factory()->create([ diff --git a/tests/Feature/DockerComposeHttpPortsTest.php b/tests/Feature/DockerComposeHttpPortsTest.php new file mode 100644 index 000000000..6b3adaa93 --- /dev/null +++ b/tests/Feature/DockerComposeHttpPortsTest.php @@ -0,0 +1,19 @@ + ['web' => $service]]); + + expect(firstDockerComposeServicePort($service))->toBe($expected[0] ?? null) + ->and(dockerComposeServicePort($compose, 'web'))->toBe($expected[0] ?? null) + ->and(dockerComposeServicePorts($compose, 'web'))->toBe($expected); +})->with([ + 'expose UDP first' => [['expose' => ['53/udp', '8080/tcp']], [8080]], + 'published UDP first' => [['ports' => ['53:53/udp', '18080:8080/tcp']], [8080]], + 'long syntax UDP first' => [['ports' => [['target' => 53, 'protocol' => 'udp'], ['target' => 8080, 'published' => 18080, 'protocol' => 'tcp']]], [8080]], + 'implicit TCP' => [['expose' => [80], 'ports' => [['target' => 8080], '18081:8081']], [80, 8080, 8081]], + 'UDP only' => [['expose' => ['53/udp'], 'ports' => [['target' => 67, 'protocol' => 'udp']]], []], + 'other protocol' => [['ports' => ['9000:9000/sctp', '8080']], [8080]], + 'duplicates' => [['expose' => ['8080/udp', '8080/tcp'], 'ports' => ['18080:8080']], [8080]], +]); diff --git a/tests/Feature/LegacyServiceRoutingPortTest.php b/tests/Feature/LegacyServiceRoutingPortTest.php new file mode 100644 index 000000000..41f0e1717 --- /dev/null +++ b/tests/Feature/LegacyServiceRoutingPortTest.php @@ -0,0 +1,94 @@ + 'httpd:2.4-alpine', 'environment' => ['SERVICE_URL_WEB']], $rawChanges); + $saved = array_replace(['image' => 'httpd:2.4-alpine', 'labels' => $labels], $savedChanges); + $service = new Service; + $service->forceFill([ + 'service_type' => $type, + 'docker_compose_raw' => Yaml::dump(['services' => ['web' => $raw]]), + 'docker_compose' => Yaml::dump(['services' => ['web' => $saved, 'other' => ['image' => 'httpd:2.4-alpine', 'labels' => ['traefik.http.services.other.loadbalancer.server.port=9000']]]]), + ]); + $application = new ServiceApplication(['name' => 'web']); + $application->setRelation('service', $service); + + expect($application->getRequiredPort())->toBe($expected); +})->with([ + 'Traefik list' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 3000], + 'Traefik map' => [['traefik.http.services.web.loadbalancer.server.port' => '3000'], 3000], + 'Caddy list' => [['caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}'], 3000], + 'Caddy map' => [['caddy_0.handle_path.0_reverse_proxy' => '{{upstreams 3000}}'], 3000], + 'matching proxies' => [['traefik.http.services.web.loadbalancer.server.port=3000', 'caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}'], 3000], + 'conflicting proxies' => [['traefik.http.services.web.loadbalancer.server.port=3000', 'caddy_0.handle_path.0_reverse_proxy={{upstreams 4000}}'], null], + 'multiple Traefik ports' => [['traefik.http.services.web.loadbalancer.server.port=3000', 'traefik.http.services.api.loadbalancer.server.port=4000'], null], + 'malformed port' => [['traefik.http.services.web.loadbalancer.server.port=3000oops'], null], + 'out of range' => [['traefik.http.services.web.loadbalancer.server.port=65536'], null], + 'negative' => [['traefik.http.services.web.loadbalancer.server.port=-1'], null], + 'mixed malformed' => [['traefik.http.services.web.loadbalancer.server.port=3000', 'caddy_0.handle_path.0_reverse_proxy=oops'], null], + 'map bare FQDN' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 3000, ['environment' => ['SERVICE_FQDN_WEB' => null]]], + 'UDP only preserves HTTP upstream' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 3000, ['expose' => ['53/udp']]], + 'zero' => [['traefik.http.services.web.loadbalancer.server.port=0'], null], + 'malformed Caddy' => [['caddy_0.handle_path.0_reverse_proxy={{upstreams 3000oops}}'], null], + 'discovery Caddy' => [['caddy_0.handle_path.0_reverse_proxy={{upstreams}}'], null], + 'no cross container' => [[], null], + 'changed image' => [['traefik.http.services.web.loadbalancer.server.port=3000'], null, [], ['image' => 'httpd:latest']], + 'missing saved image' => [['traefik.http.services.web.loadbalancer.server.port=3000'], null, [], ['image' => null]], + 'no direct declaration' => [['traefik.http.services.web.loadbalancer.server.port=3000'], null, ['environment' => ['URL=${SERVICE_URL_WEB}']]], + 'explicit magic wins' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 8080, ['environment' => ['SERVICE_URL_WEB_8080']]], + 'Compose port wins' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 8080, ['expose' => [8080]]], + 'known template excluded' => [['traefik.http.services.web.loadbalancer.server.port=3000'], 80, [], [], 'wordpress-without-database'], +]); + +it('preserves legacy upstreams across repeated parsing without adding public ports', function (int $version, ?int $override) { + InstanceSettings::forceCreate(['id' => 0]); + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['generate_exact_labels' => false]); + $destination = StandaloneDocker::where('server_id', $server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $server->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'name' => 'legacy-stack', + 'service_type' => null, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: httpd:2.4-alpine\n environment:\n - SERVICE_URL_WEB\n", + 'docker_compose' => Yaml::dump(['services' => ['web' => ['image' => 'httpd:2.4-alpine', 'labels' => ['traefik.http.services.web.loadbalancer.server.port=3000']]]]), + 'compose_parsing_version' => (string) $version, + ]); + ServiceApplication::create([ + 'name' => 'web', + 'service_id' => $service->id, + 'image' => 'httpd:2.4-alpine', + 'fqdn' => 'https://legacy.example.com', + 'domain_port_overrides' => $override === null ? null : ['https://legacy.example.com' => $override], + ]); + + for ($iteration = 0; $iteration < 2; $iteration++) { + $parsed = $service->fresh()->parse(); + $labels = collect($parsed['services']['web']['labels']); + $ports = $labels->filter(fn ($label) => str_contains($label, '.loadbalancer.server.port=')); + expect($ports)->not->toBeEmpty(); + foreach ($ports as $port) { + expect($port)->toEndWith('='.($override ?? 3000)); + } + expect($labels->filter(fn ($label) => str_contains($label, 'reverse_proxy'))->implode(' '))->toContain('{{upstreams '.($override ?? 3000).'}}'); + expect($service->fresh()->applications()->first()->fqdn)->toBe('https://legacy.example.com'); + } +})->with([4, 5])->with([null, 8080]); diff --git a/tests/Feature/PreviewDomainPortOverridesTest.php b/tests/Feature/PreviewDomainPortOverridesTest.php index b40193cb2..18d382933 100644 --- a/tests/Feature/PreviewDomainPortOverridesTest.php +++ b/tests/Feature/PreviewDomainPortOverridesTest.php @@ -269,7 +269,7 @@ function createPreviewForPortTests(Application $application, int $pullRequestId, it('saves compose preview domain port overrides per service without putting the port in the public URL', function () { $this->application->update([ 'build_pack' => 'dockercompose', - 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n", + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [8080]\n api:\n image: nginx:alpine\n expose: [3000]\n", 'docker_compose_domains' => null, ]); @@ -537,7 +537,7 @@ function createPreviewForPortTests(Application $application, int $pullRequestId, ->toBeFalse(); }); -it('uses ports_exposes as the compose preview fallback when a domain has no override', function () { +it('leaves single-service compose preview ports to proxy discovery when a domain has no override', function () { $this->application->update([ 'build_pack' => 'dockercompose', 'compose_parsing_version' => '3', @@ -565,9 +565,9 @@ function createPreviewForPortTests(Application $application, int $pullRequestId, $parsedCompose = applicationParser($this->application->fresh(), 118, $preview->id); $labels = collect(data_get($parsedCompose, 'services.frontend-pr-118.labels')); - expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=4000'))) - ->toBeTrue() - ->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 4000}}'))) + expect($labels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) + ->toBeFalse() + ->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams}}'))) ->toBeTrue(); }); @@ -672,3 +672,91 @@ function createPreviewForPortTests(Application $application, int $pullRequestId, ->assertSet('showPortWarningModal', false) ->assertDispatched('success'); }); + +it('checks compose preview ports against the selected service when saving domains', function (string $action, int $port, bool $warn) { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => <<<'YAML' +services: + web: + image: nginx:alpine + expose: + - 8080 + - 8081 + ports: + - target: 8082 + published: 18082 + - "18083:8083" + api: + image: nginx:alpine + expose: + - 9090 +YAML, + 'docker_compose_domains' => json_encode([ + 'web' => ['domain' => 'https://existing.example.com'], + 'api' => ['domain' => 'https://api.example.com:9090'], + ]), + ]); + $preview = createPreviewForPortTests($this->application, 130, [ + 'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://existing.example.com']]), + 'fqdn' => 'https://existing.example.com', + ]); + + $component = Livewire::test(PreviewDomains::class, ['preview' => $preview]); + if ($action === 'add') { + $component->set('newDomainService', 'web') + ->set('newDomainParts.host', 'new.example.com') + ->set('newDomainParts.port', (string) $port) + ->call('addDomain'); + $domain = 'https://new.example.com'; + } else { + $component->call('startEdit', 0) + ->set('editingDomainParts.port', (string) $port) + ->call('updateDomain'); + $domain = 'https://existing.example.com'; + } + + $component->assertHasNoErrors()->assertSet('showPortWarningModal', $warn); + + if ($warn) { + expect($preview->fresh()->domain_port_overrides ?? [])->not->toHaveKey($domain); + $component->assertSet('unrecognizedPort', $port) + ->call('confirmUseUnknownPort') + ->assertSet('showPortWarningModal', false); + } + + $component->assertDispatched('success'); + expect($preview->fresh()->domain_port_overrides[$domain] ?? null)->toBe($port); +})->with(['add', 'edit'])->with([ + 'first exposed port' => [8080, false], + 'second exposed port' => [8081, false], + 'long syntax target' => [8082, false], + 'short syntax target' => [8083, false], + 'global port' => [3000, true], + 'another service port' => [9090, true], + 'published host port' => [18082, true], +]); + +it('keeps an existing custom compose preview port without another warning', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose: [8080]\n", + 'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://existing.example.com:7070']]), + ]); + $preview = createPreviewForPortTests($this->application, 131, [ + 'docker_compose_domains' => json_encode(['web' => ['domain' => 'https://existing.example.com:7070']]), + 'fqdn' => 'https://existing.example.com:7070', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('startEdit', 0) + ->assertSet('editingDomainParts.port', '7070') + ->call('updateDomain') + ->assertHasNoErrors() + ->assertSet('showPortWarningModal', false) + ->assertDispatched('success'); + + expect($preview->fresh()->domain_port_overrides['https://existing.example.com'] ?? null)->toBe(7070); +}); diff --git a/tests/Feature/Service/EditDomainPortValidationTest.php b/tests/Feature/Service/EditDomainPortValidationTest.php index 0d569574f..29e1f0b33 100644 --- a/tests/Feature/Service/EditDomainPortValidationTest.php +++ b/tests/Feature/Service/EditDomainPortValidationTest.php @@ -48,6 +48,7 @@ // Create service with a name that maps to a template with required port $this->service = Service::factory()->create([ 'name' => 'supabase-test123', + 'service_type' => 'supabase', 'server_id' => $this->server->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), @@ -99,6 +100,67 @@ function get_service_templates_mock() ->assertOk(); }); +it('preserves a legacy embedded port when only the description changes', function () { + ServiceApplication::query()->whereKey($this->serviceApplication->id)->update([ + 'fqdn' => 'http://example.com:8080', + 'domain_port_overrides' => null, + ]); + + Livewire::test(Index::class, [ + 'serviceApplication' => $this->serviceApplication->fresh(), + ]) + ->set('description', 'Updated description') + ->call('submitApplication') + ->assertHasNoErrors() + ->assertSet('showPortWarningModal', false) + ->assertSet('fqdn', 'http://example.com:8080'); + + expect($this->serviceApplication->fresh()) + ->description->toBe('Updated description') + ->fqdn->toBe('http://example.com') + ->domain_port_overrides->toBe(['http://example.com' => 8080]); +}); + +it('loads a legacy embedded port in the domain editor', function () { + ServiceApplication::query()->whereKey($this->serviceApplication->id)->update([ + 'fqdn' => 'http://example.com:8080', + 'domain_port_overrides' => null, + ]); + + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->assertSet('fqdn', 'http://example.com:8080') + ->assertOk(); +}); + +it('prefers a persisted override over a legacy embedded port', function () { + ServiceApplication::query()->whereKey($this->serviceApplication->id)->update([ + 'fqdn' => 'http://example.com:8080', + 'domain_port_overrides' => json_encode(['http://example.com' => 3000]), + ]); + + expect($this->serviceApplication->fresh()->url)->toBe('http://example.com:3000'); +}); + +it('allows explicit removal of a legacy embedded port', function () { + ServiceApplication::query()->whereKey($this->serviceApplication->id)->update([ + 'fqdn' => 'http://example.com:8080', + 'domain_port_overrides' => null, + ]); + + Livewire::test(EditDomain::class, ['applicationId' => $this->serviceApplication->id]) + ->set('fqdn', 'http://example.com') + ->call('submit') + ->assertSet('showPortWarningModal', true) + ->call('confirmRemovePort') + ->assertHasNoErrors() + ->assertSet('showPortWarningModal', false); + + expect($this->serviceApplication->fresh()) + ->fqdn->toBe('http://example.com') + ->url->toBe('http://example.com') + ->domain_port_overrides->toBeNull(); +}); + it('initializes route state when mounting a service application directly', function () { Livewire::test(Index::class, [ 'serviceApplication' => $this->serviceApplication, diff --git a/tests/Feature/ServiceRoutingPortTest.php b/tests/Feature/ServiceRoutingPortTest.php new file mode 100644 index 000000000..dc4b48929 --- /dev/null +++ b/tests/Feature/ServiceRoutingPortTest.php @@ -0,0 +1,62 @@ +toBe('sqlite'); + expect(config('database.connections.'.config('database.default').'.database'))->toBe(':memory:'); + InstanceSettings::forceCreate(['id' => 0]); + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['generate_exact_labels' => false]); + $destination = StandaloneDocker::where('server_id', $server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $server->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'name' => 'custom-stack', + 'service_type' => $type, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => "services:\n {$name}:\n image: httpd:2.4-alpine\n{$declarations}", + 'compose_parsing_version' => (string) $version, + ]); + ServiceApplication::create([ + 'name' => $name, + 'service_id' => $service->id, + 'image' => 'httpd:2.4-alpine', + 'fqdn' => 'https://apache.example.com', + ]); + $parsed = $service->fresh()->parse(); + $labels = collect(data_get($parsed, 'services'))->flatMap(fn ($entry) => data_get($entry, 'labels', [])); + $ports = $labels->filter(fn ($label) => str_contains($label, '.loadbalancer.server.port='))->values()->all(); + if ($expected === null) { + expect($ports)->toBeEmpty(); + expect($labels->filter(fn ($label) => str_contains($label, 'reverse_proxy'))->implode(' '))->not->toContain('upstreams 3000'); + } else { + expect($ports)->not->toBeEmpty(); + foreach ($ports as $port) { + expect($port)->toEndWith('.loadbalancer.server.port='.$expected); + } + } +})->with([4, 5])->with([ + 'UDP before HTTP' => ['web', null, " expose: ['53/udp', '8080/tcp']\n", 8080], + 'UDP only discovery' => ['web', null, " expose: ['53/udp']\n", null], + 'custom collision' => ['grafana', null, " expose: [80]\n", 80], + 'custom discovery' => ['grafana', null, '', null], + 'unrelated template child' => ['web', 'grafana', '', null], + 'real template' => ['wordpress', 'wordpress-without-database', " environment:\n - SERVICE_URL_WORDPRESS\n", 80], + 'explicit magic port' => ['web', null, " expose: [80]\n environment:\n - SERVICE_URL_WEB_8080\n", 8080], +]); diff --git a/tests/Feature/ServiceTemplatePortLookupTest.php b/tests/Feature/ServiceTemplatePortLookupTest.php index 1e228811d..28f8cee3c 100644 --- a/tests/Feature/ServiceTemplatePortLookupTest.php +++ b/tests/Feature/ServiceTemplatePortLookupTest.php @@ -25,3 +25,9 @@ expect($app->getRequiredPort())->toBe(80); }); + +it('does not infer a template port from a custom display name', function () { + $service = new Service(['name' => 'grafana-custom', 'service_type' => null]); + + expect($service->getRequiredPort())->toBeNull(); +});