From b9ad48e1042e00279b386b8979b5d6510480d2fb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:02:19 +0100 Subject: [PATCH] fix(deployments): pass port-suffixed compose service URL variables Docker Compose applications dropped the stored SERVICE_URL__ and SERVICE_FQDN__ variables and only generated the forms without a port, so compose references to them were blank. Generate the port-suffixed forms from the current domain for runtime and build-time, production and preview deployments. Closes #12427 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/ApplicationDeploymentJob.php | 97 ++++++++----------- ...ationDeploymentControlVarFilteringTest.php | 57 +++++++++++ 2 files changed, 97 insertions(+), 57 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 404d3a9ad3..3944b6c0dc 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1938,22 +1938,8 @@ 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 ?: '[]', 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 = 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); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString()); - $envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) { + $envs->push($key.'='.$value); } // Generate SERVICE_NAME for dockercompose services from processed compose @@ -2015,21 +2001,8 @@ 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') ?: '[]', true) ?: []); - - // Generate SERVICE_FQDN & SERVICE_URL for dockercompose - foreach ($domains as $forServiceName => $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); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString()); - $envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) { + $envs->push($key.'='.$value); } // Generate SERVICE_NAME for dockercompose services @@ -2112,6 +2085,38 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } + /** + * SERVICE_URL_* and SERVICE_FQDN_* values from the compose domains. Port-suffixed variables + * that the parser stored (SERVICE_URL_APP_3000) get the same value with the port appended. + * + * @return array + */ + private function composeServiceUrlVariables(?string $domainsJson): array + { + $storedKeys = $this->application->environment_variables()->pluck('key'); + $variables = []; + foreach (json_decode($domainsJson ?: '[]', true) ?: [] as $forServiceName => $domain) { + $parsedDomain = composeDomainEntryString($domain); + if (blank($parsedDomain)) { + continue; + } + $url = Url::fromString(str($parsedDomain)->explode(',')->first())->withPort(null); + $values = ['URL' => $url->__toString(), 'FQDN' => $url->getHost()]; + $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper()->value(); + foreach ($values as $type => $value) { + $variables["SERVICE_{$type}_{$serviceEnvKey}"] = $value; + } + $pattern = '/^SERVICE_(URL|FQDN)_'.preg_quote($serviceEnvKey, '/').'_(\d+)$/'; + foreach ($storedKeys as $key) { + if (preg_match($pattern, $key, $matches)) { + $variables[$key] = "{$values[$matches[1]]}:{$matches[2]}"; + } + } + } + + return $variables; + } + private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool { $key = str($environmentVariable->key); @@ -2333,19 +2338,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments - $domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []); - foreach ($domains as $forServiceName => $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); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString()); - $envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) { + $envs_dict[$key] = escapeBashEnvValue($value); } } else { // Generate SERVICE_NAME for preview deployments @@ -2356,19 +2350,8 @@ 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') ?: '[]', true) ?: []); - foreach ($domains as $forServiceName => $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); - $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper(); - $envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString()); - $envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn); - } + foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) { + $envs_dict[$key] = escapeBashEnvValue($value); } } } diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 3d6707505f..a4527e59d6 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -2143,3 +2143,60 @@ YAML; 'production' => 0, 'preview' => 42, ]); + +it('passes port-suffixed compose service URL variables to production and preview deployments', function (int $pullRequestId) { + $compose = <<<'YAML' +services: + docmost: + build: . + environment: + - SERVICE_URL_DOCMOST_3000 + - APP_URL=$SERVICE_URL_DOCMOST_3000 +YAML; + + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'docker_compose' => $compose, + 'docker_compose_domains' => json_encode(['docmost' => ['domain' => 'https://docs.example.com']]), + ]); + + foreach ([ + 'SERVICE_URL_DOCMOST' => 'https://docs.example.com', + 'SERVICE_FQDN_DOCMOST' => 'docs.example.com', + 'SERVICE_URL_DOCMOST_3000' => 'https://stale.example.com:3000', + 'SERVICE_FQDN_DOCMOST_3000' => 'stale.example.com:3000', + ] as $key => $value) { + createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value, 'is_runtime' => true, 'is_buildtime' => true]); + } + + $overrides = ['pull_request_id' => $pullRequestId]; + $expectedHost = 'docs.example.com'; + if ($pullRequestId !== 0) { + $expectedHost = '7.docs.example.com'; + $overrides['preview'] = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/7', + 'docker_compose_domains' => json_encode(['docmost' => ['domain' => "https://{$expectedHost}"]]), + ]); + } + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, $overrides); + + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + expect($runtimeEnvs) + ->toContain("SERVICE_URL_DOCMOST=https://{$expectedHost}") + ->toContain("SERVICE_URL_DOCMOST_3000=https://{$expectedHost}:3000") + ->toContain("SERVICE_FQDN_DOCMOST_3000={$expectedHost}:3000") + ->and($runtimeEnvs->implode("\n"))->not->toContain('stale.example.com'); + + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')->implode("\n"); + expect($buildtimeEnvs) + ->toContain("SERVICE_URL_DOCMOST_3000='https://{$expectedHost}:3000'") + ->toContain("SERVICE_FQDN_DOCMOST_3000='{$expectedHost}:3000'") + ->not->toContain('stale.example.com'); +})->with([ + 'production' => [0], + 'preview' => [7], +]);