From 208f7720bcc75fa6edca198e8875b9018a52cd59 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:19:38 +0200 Subject: [PATCH] fix: avoid inherited compose ports and defer archive inspection Prevent multi-service Compose domains from inheriting the application port, and defer PostgreSQL custom-format archive inspection to pg_restore. --- app/Livewire/Project/Application/Domains.php | 7 ++ .../Project/Application/PreviewDomains.php | 7 ++ app/Support/DatabaseBackupFileValidator.php | 9 +-- bootstrap/helpers/parsers.php | 6 +- bootstrap/helpers/shared.php | 8 ++- tests/Feature/ApplicationDomainsTest.php | 65 +++++++++++++++++++ ...licationParserDockerComposeDomainsTest.php | 34 ++++++++++ .../DatabaseBackupUploadValidationTest.php | 8 +-- 8 files changed, 130 insertions(+), 14 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 839c61e36..62153d175 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -569,6 +569,13 @@ protected function effectiveDomainInternalPort(string $url, ?string $service = n ]; } + if ($this->isCompose && $service !== null && count($this->composeServices) > 1) { + return [ + 'internal_port' => null, + '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] diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index e6e9f5d60..66eb2f5e0 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -537,6 +537,13 @@ private function effectiveDomainInternalPort(string $url, ?string $service = nul ]; } + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null && count($this->composeServices()) > 1) { + return [ + 'internal_port' => null, + '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/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 84e629fe1..2c1de948b 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,11 +90,8 @@ public static function fileContainsPostgresqlProgramExecution(string $path): boo public static function containsPostgresqlProgramExecution(string $sql): bool { - $requireStatementBoundary = true; - if (str_starts_with($sql, 'PGDMP')) { - $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; - $requireStatementBoundary = false; + return false; } $withoutComments = self::stripSqlComments($sql); @@ -103,9 +100,7 @@ public static function containsPostgresqlProgramExecution(string $sql): bool return true; } - $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; - - return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 03ebf5af7..dd8ea29c8 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -390,6 +390,9 @@ 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', [])), @@ -1355,7 +1358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ? ($previewForPorts?->domain_port_overrides ?? []) : ($originalResource->domain_port_overrides ?? []); $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); + $onlyPort = firstDockerComposeServicePort($service) + ?? ($applicationServiceCount === 1 ? ($exposedPorts[0] ?? null) : null); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index bc5af1e86..f5db32e88 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3318,7 +3318,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id !== 0) { $definedNetwork = collect(["{$resource->uuid}-$pull_request_id"]); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $server, $pull_request_id, $preview_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) { $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3916,7 +3919,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal ? ($resource->domain_port_overrides ?? []) : ($preview?->domain_port_overrides ?? []); $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); + $onlyPort = firstDockerComposeServicePort($service) + ?? ($usesSharedApplicationPort ? ($exposedPorts[0] ?? null) : null); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 396cccbfc..1cc6d05e1 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -2436,6 +2436,25 @@ ->assertDontSee('Internal port 3000'); }); +it('does not show an application port as the inherited port for a compose service without a declared port', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n backend:\n build: ./backend\n frontend:\n build: ./frontend\n", + 'docker_compose_domains' => json_encode([ + 'backend' => ['domain' => 'https://api.example.com'], + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + 'fqdn' => null, + 'domain_port_overrides' => null, + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSet('domainRows.0.internal_port', null) + ->assertSet('domainRows.1.internal_port', null) + ->assertDontSee('Internal port 3000'); +}); + it('shows the detected compose service port for preview domains', function () { $this->application->update([ 'build_pack' => 'dockercompose', @@ -2459,6 +2478,27 @@ ->assertDontSee('Internal port 3000'); }); +it('does not show an application port for a preview compose service without a declared port', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n backend:\n build: ./backend\n frontend:\n build: ./frontend\n", + ]); + + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 8070, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/8070', + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://preview.example.com'], + ]), + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertSet('domainRows.0.internal_port', null) + ->assertDontSee('Internal port 3000'); +}); + it('keeps a legacy port-bearing url port in the edit field as an internal port override', function () { $this->application->update([ 'ports_exposes' => '3000,8080', @@ -2514,6 +2554,31 @@ ->toHaveKey('https://api.example.com', 4000); }); +it('saves an unrecognized compose domain port after confirming the warning', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'fqdn' => null, + 'ports_exposes' => '3000', + 'docker_compose_raw' => "services:\n frontend:\n build: ./frontend\n", + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + 'domain_port_overrides' => null, + ]); + + Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->call('startEdit', 0) + ->set('editingDomainParts.port', '80') + ->call('updateDomain') + ->assertSet('showPortWarningModal', true) + ->call('confirmUseUnknownPort') + ->assertSet('showPortWarningModal', false) + ->assertDispatched('success'); + + expect($this->application->fresh()->domain_port_overrides) + ->toBe(['https://app.example.com' => 80]); +}); + it('prunes a compose domain port override when that domain is removed', function () { $this->application->update([ 'build_pack' => 'dockercompose', diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 30305ef59..c20778202 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -631,3 +631,37 @@ function disableExactProxyLabels(Application $application): Application 'short port syntax' => " ports:\n - '18069:8069'", 'long port syntax' => " ports:\n - target: 8069\n published: 18069", ]); + +test('applicationParser does not apply an application port to compose services without a declared port', function () { + $application = disableExactProxyLabels(Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'ports_exposes' => '3000', + 'docker_compose_raw' => <<<'YAML' +services: + postgres: + image: postgres:16-alpine + backend: + build: ./backend + frontend: + build: ./frontend +YAML, + 'fqdn' => null, + 'domain_port_overrides' => null, + 'docker_compose_domains' => json_encode([ + 'backend' => ['domain' => 'https://api.example.com'], + 'frontend' => ['domain' => 'https://app.example.com'], + ]), + ])); + + $services = data_get(applicationParser($application->fresh()), 'services'); + $backendLabels = collect(data_get($services, 'backend.labels')); + $frontendLabels = collect(data_get($services, 'frontend.labels')); + + expect($backendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) + ->toBeFalse() + ->and($frontendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) + ->toBeFalse(); +}); diff --git a/tests/Feature/DatabaseBackupUploadValidationTest.php b/tests/Feature/DatabaseBackupUploadValidationTest.php index 88978eeab..f8416fe6c 100644 --- a/tests/Feature/DatabaseBackupUploadValidationTest.php +++ b/tests/Feature/DatabaseBackupUploadValidationTest.php @@ -220,16 +220,16 @@ public function getMorphClass(): string expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse(); }); -test('file scanner detects program execution payloads inside custom format archives', function () { +test('file scanner defers custom format archives to pg_restore inspection', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload"); - expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue(); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); -test('file scanner detects program execution payloads inside gzipped custom format archives', function () { +test('file scanner defers gzipped custom format archives to pg_restore inspection', function () { $archive = writeScanPayload("PGDMP\0binary COPY records FROM PROGRAM payload", gzip: true); - expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeTrue(); + expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($archive))->toBeFalse(); }); test('file scanner allows custom format archives without program execution', function () {