From fc4562ede7e35165d8c388abc424210de2d9b06b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:29:39 +0200 Subject: [PATCH] fix(services): treat SERVICE_FQDN_ twin as a copy of SERVICE_URL_ declaration A service that declares SERVICE_FQDN_X_PORT while the named service declares SERVICE_URL_X_PORT (same value) now counts as a copy, like an exact copy, so it does not take the named service's domain. Also replace the Postgres-only regex in the magic variable loop with the same check in PHP. --- bootstrap/helpers/domains.php | 18 +++++++++++---- bootstrap/helpers/parsers.php | 8 +++---- .../ServiceSharedMagicUrlOwnerTest.php | 22 +++++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index e904c34fe1..704c90988f 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -594,7 +594,7 @@ function findServiceApplicationForEnvName(Service $resource, string $envServiceN /** * Check if a service copies a SERVICE_FQDN_ / SERVICE_URL_ declaration of the application named in it. - * The copy (same key, same value) must not generate a domain or overwrite the variable (#11924). + * The copy (same name, port and value; URL or FQDN) must not generate a domain or overwrite the variable (#11924). * Declarations with another port or path share the host on purpose (for example Appwrite). * * @param array|Collection $services Parsed compose services @@ -618,9 +618,19 @@ function isCopiedServiceUrlDeclaration( $ownerDeclarations = $declarationsOf($owner->name); $currentDeclarations = $declarationsOf($current->name); - return $ownerDeclarations->has($key) - && $currentDeclarations->has($key) - && (string) $ownerDeclarations->get($key) === (string) $currentDeclarations->get($key); + if (! $currentDeclarations->has($key)) { + return false; + } + + // SERVICE_URL_X_3000 and SERVICE_FQDN_X_3000 write the same variable pair, so either one counts. + $twinKey = str_starts_with($key, 'SERVICE_URL_') + ? 'SERVICE_FQDN_'.substr($key, strlen('SERVICE_URL_')) + : 'SERVICE_URL_'.substr($key, strlen('SERVICE_FQDN_')); + $value = (string) $currentDeclarations->get($key); + + return collect([$key, $twinKey])->contains( + fn (string $ownerKey) => $ownerDeclarations->has($ownerKey) && (string) $ownerDeclarations->get($ownerKey) === $value + ); } /** diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index eb2d8b472e..08162aea74 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2671,8 +2671,8 @@ function serviceParser(Service $resource): Collection // Also check if a port-suffixed version exists (e.g., SERVICE_FQDN_UMAMI_3000) $portSuffixedExists = $resource->environment_variables() ->where('key', 'LIKE', $key->value().'_%') - ->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$']) - ->exists(); + ->pluck('key') + ->contains(fn ($envKey) => preg_match('/^'.preg_quote($key->value(), '/').'_[0-9]+$/', $envKey) === 1); $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+$/'); @@ -2716,8 +2716,8 @@ function serviceParser(Service $resource): Collection // Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791) $portSuffixedExists = $resource->environment_variables() ->where('key', 'LIKE', $key->value().'_%') - ->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$']) - ->exists(); + ->pluck('key') + ->contains(fn ($envKey) => preg_match('/^'.preg_quote($key->value(), '/').'_[0-9]+$/', $envKey) === 1); $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+$/'); diff --git a/tests/Feature/ServiceSharedMagicUrlOwnerTest.php b/tests/Feature/ServiceSharedMagicUrlOwnerTest.php index 8da05c9222..acc2e64832 100644 --- a/tests/Feature/ServiceSharedMagicUrlOwnerTest.php +++ b/tests/Feature/ServiceSharedMagicUrlOwnerTest.php @@ -154,3 +154,25 @@ it('fixes SERVICE_URL_TWENTY for installs where worker already has a generated d expect($this->service->environment_variables()->where('key', 'SERVICE_URL_TWENTY')->value('value'))->toBe('https://crm.example.com') ->and($this->service->applications()->where('name', 'worker')->value('fqdn'))->toBe('http://twenty-stale.10.0.1.1.sslip.io'); }); + +it('treats SERVICE_FQDN_ with the same name, port and value as a copy of SERVICE_URL_', function () { + $this->service->update(['docker_compose_raw' => <<<'YAML' +services: + twenty: + image: twentycrm/twenty:v1.15 + environment: + - SERVICE_URL_TWENTY_3000 + worker: + image: twentycrm/twenty:v1.15 + environment: + - SERVICE_FQDN_TWENTY_3000 +YAML]); + + $this->service->parse(); + $this->service->applications()->where('name', 'twenty')->first()->update(['fqdn' => 'https://crm.example.com']); + $this->service->fresh()->parse(); + + expect($this->service->environment_variables()->where('key', 'SERVICE_URL_TWENTY')->value('value'))->toBe('https://crm.example.com') + ->and($this->service->environment_variables()->where('key', 'SERVICE_FQDN_TWENTY_3000')->value('value'))->toBe('crm.example.com:3000') + ->and($this->service->applications()->where('name', 'worker')->value('fqdn'))->toBeNull(); +});