fix: properly handle SERVICE_URL and SERVICE_FQDN for abbreviated service names (#7243)

Parse template variables directly instead of generating from container names. Always create both SERVICE_URL and SERVICE_FQDN pairs together. Properly separate scheme handling (URL has scheme, FQDN doesn't). Add comprehensive test coverage.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-11-18 22:26:11 +01:00
parent 8af6339695
commit 56f32d0f87
5 changed files with 909 additions and 204 deletions

View file

@ -189,65 +189,51 @@ public function isBackupSolutionAvailable()
public function getRequiredPort(): ?int
{
try {
// Normalize container name same way as variable creation
// (uppercase, replace - and . with _)
$normalizedName = str($this->name)
->upper()
->replace('-', '_')
->replace('.', '_')
->value();
// Get all environment variables from the service
$serviceEnvVars = $this->service->environment_variables()->get();
// Parse the Docker Compose to find SERVICE_URL/SERVICE_FQDN variables DIRECTLY DECLARED
// for this specific service container (not just referenced from other containers)
$dockerComposeRaw = data_get($this->service, 'docker_compose_raw');
if (! $dockerComposeRaw) {
// Fall back to service-level port if no compose file
return $this->service->getRequiredPort();
}
// Look for SERVICE_FQDN_* or SERVICE_URL_* variables that match this container
foreach ($serviceEnvVars as $envVar) {
$key = str($envVar->key);
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
if (! $serviceConfig) {
return $this->service->getRequiredPort();
}
// Check if this is a SERVICE_FQDN_* or SERVICE_URL_* variable
if (! $key->startsWith('SERVICE_FQDN_') && ! $key->startsWith('SERVICE_URL_')) {
continue;
}
// Extract the part after SERVICE_FQDN_ or SERVICE_URL_
if ($key->startsWith('SERVICE_FQDN_')) {
$suffix = $key->after('SERVICE_FQDN_');
} else {
$suffix = $key->after('SERVICE_URL_');
}
$environment = data_get($serviceConfig, 'environment', []);
// Check if this variable starts with our normalized container name
// Format: {NORMALIZED_NAME}_{PORT} or just {NORMALIZED_NAME}
if (! $suffix->startsWith($normalizedName)) {
\Log::debug('[ServiceApplication::getRequiredPort] Suffix does not match container', [
'expected_start' => $normalizedName,
'actual_suffix' => $suffix->value(),
]);
// Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment
// (not variables that are merely referenced with ${VAR} syntax)
$portFound = null;
foreach ($environment as $envVar) {
if (is_string($envVar)) {
// Extract variable name (before '=' if present)
$envVarName = str($envVar)->before('=')->trim();
continue;
}
// Check if there's a port suffix after the container name
// The suffix should be exactly NORMALIZED_NAME or NORMALIZED_NAME_PORT
$afterName = $suffix->after($normalizedName)->value();
// If there's content after the name, it should start with underscore
if ($afterName !== '' && str($afterName)->startsWith('_')) {
// Extract port: _3210 -> 3210
$port = str($afterName)->after('_')->value();
// Validate that the extracted port is numeric
if (is_numeric($port)) {
\Log::debug('[ServiceApplication::getRequiredPort] MATCH FOUND - Returning port', [
'port' => (int) $port,
]);
return (int) $port;
// Only process direct declarations
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
// Parse to check if it has a port suffix
$parsed = parseServiceEnvironmentVariable($envVarName->value());
if ($parsed['has_port'] && $parsed['port']) {
// Found a port-specific variable for this service
$portFound = (int) $parsed['port'];
break;
}
}
}
}
// Fall back to service-level port if no port-specific variable is found
$fallbackPort = $this->service->getRequiredPort();
// If a port was found in the template, return it
if ($portFound !== null) {
return $portFound;
}
return $fallbackPort;
// No port-specific variables found for this service, return null
// (DO NOT fall back to service-level port, as that applies to all services)
return null;
} catch (\Throwable $e) {
return null;
}

View file

@ -514,84 +514,99 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$key = str($key);
$value = replaceVariables($value);
$command = parseCommandFromMagicEnvVariable($key);
if ($command->value() === 'FQDN') {
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
$originalFqdnFor = str($fqdnFor)->replace('_', '-');
if (str($fqdnFor)->contains('-')) {
$fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
if ($command->value() === 'FQDN' || $command->value() === 'URL') {
// ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs regardless of which one is in template
$parsed = parseServiceEnvironmentVariable($key->value());
$serviceName = $parsed['service_name'];
$port = $parsed['port'];
// Extract case-preserved service name from template
$strKey = str($key->value());
if ($parsed['has_port']) {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceNamePreserved = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
} else {
$serviceNamePreserved = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
}
} else {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceNamePreserved = $strKey->after('SERVICE_URL_')->value();
} else {
$serviceNamePreserved = $strKey->after('SERVICE_FQDN_')->value();
}
}
// Generated FQDN & URL
$fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
$url = generateUrl(server: $server, random: "$originalFqdnFor-$uuid");
$originalServiceName = str($serviceName)->replace('_', '-');
if (str($serviceName)->contains('-')) {
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_');
}
// Generate BOTH FQDN & URL
$fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version);
$url = generateUrl(server: $server, random: "$originalServiceName-$uuid");
// IMPORTANT: SERVICE_FQDN env vars should NOT contain scheme (host only)
// But $fqdn variable itself may contain scheme (used for database domain field)
// Strip scheme for environment variable values
$fqdnValueForEnv = str($fqdn)->after('://')->value();
// Append port if specified
$urlWithPort = $url;
$fqdnWithPort = $fqdn;
$fqdnValueForEnvWithPort = $fqdnValueForEnv;
if ($port && is_numeric($port)) {
$urlWithPort = "$url:$port";
$fqdnWithPort = "$fqdn:$port";
$fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port";
}
// ALWAYS create base SERVICE_FQDN variable (host only, no scheme)
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'key' => "SERVICE_FQDN_{$serviceNamePreserved}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'value' => $fqdnValueForEnv,
'is_preview' => false,
]);
if ($resource->build_pack === 'dockercompose') {
// Check if a service with this name actually exists
$serviceExists = false;
foreach ($services as $serviceName => $service) {
$transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
if ($transformedServiceName === $fqdnFor) {
$serviceExists = true;
break;
}
}
// Only add domain if the service exists
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
$domainExists = data_get($domains->get($fqdnFor), 'domain');
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) {
$envExists->update([
'value' => $url,
]);
}
if (is_null($domainExists)) {
// Put URL in the domains array instead of FQDN
$domains->put((string) $fqdnFor, [
'domain' => $url,
]);
$resource->docker_compose_domains = $domains->toJson();
$resource->save();
}
}
}
} elseif ($command->value() === 'URL') {
// SERVICE_URL_APP or SERVICE_URL_APP_3000
// Detect if there's a port suffix
$parsed = parseServiceEnvironmentVariable($key->value());
$urlFor = $parsed['service_name'];
$port = $parsed['port'];
$originalUrlFor = str($urlFor)->replace('_', '-');
if (str($urlFor)->contains('-')) {
$urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
}
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
// Append port if specified
$urlWithPort = $url;
if ($port && is_numeric($port)) {
$urlWithPort = "$url:$port";
}
// ALWAYS create base SERVICE_URL variable (with scheme)
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'key' => "SERVICE_URL_{$serviceNamePreserved}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
]);
// If port-specific, ALSO create port-specific pairs
if ($parsed['has_port'] && $port) {
$resource->environment_variables()->firstOrCreate([
'key' => "SERVICE_FQDN_{$serviceNamePreserved}_{$port}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnvWithPort,
'is_preview' => false,
]);
$resource->environment_variables()->firstOrCreate([
'key' => "SERVICE_URL_{$serviceNamePreserved}_{$port}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $urlWithPort,
'is_preview' => false,
]);
}
if ($resource->build_pack === 'dockercompose') {
// Check if a service with this name actually exists
$serviceExists = false;
foreach ($services as $serviceName => $service) {
$transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
if ($transformedServiceName === $urlFor) {
foreach ($services as $serviceNameKey => $service) {
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
if ($transformedServiceName === $serviceName) {
$serviceExists = true;
break;
}
@ -600,16 +615,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Only add domain if the service exists
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
$domainExists = data_get($domains->get($urlFor), 'domain');
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
if ($domainExists !== $envExists->value) {
$envExists->update([
'value' => $urlWithPort,
]);
}
$domainExists = data_get($domains->get($serviceName), 'domain');
// Update domain using URL with port if applicable
$domainValue = $port ? $urlWithPort : $url;
if (is_null($domainExists)) {
$domains->put((string) $urlFor, [
'domain' => $urlWithPort,
$domains->put((string) $serviceName, [
'domain' => $domainValue,
]);
$resource->docker_compose_domains = $domains->toJson();
$resource->save();
@ -1584,92 +1597,109 @@ function serviceParser(Service $resource): Collection
}
// Get magic environments where we need to preset the FQDN / URL
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 or SERVICE_URL_APP or SERVICE_URL_APP_3000
// ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs regardless of which one is in template
$parsed = parseServiceEnvironmentVariable($key->value());
if ($key->startsWith('SERVICE_FQDN_')) {
$urlFor = null;
$fqdnFor = $parsed['service_name'];
}
if ($key->startsWith('SERVICE_URL_')) {
$fqdnFor = null;
$urlFor = $parsed['service_name'];
// Extract service name preserving original case from template
$strKey = str($key->value());
if ($parsed['has_port']) {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
} else {
continue;
}
} else {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->value();
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
} else {
continue;
}
}
$port = $parsed['port'];
$fqdnFor = $parsed['service_name'];
if (blank($savedService->fqdn)) {
if ($fqdnFor) {
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
} else {
$fqdn = generateFqdn(server: $server, random: "{$savedService->name}-$uuid", parserVersion: $resource->compose_parsing_version);
}
if ($urlFor) {
$url = generateUrl($server, "$urlFor-$uuid");
} else {
$url = generateUrl($server, "{$savedService->name}-$uuid");
}
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
$url = generateUrl($server, "$fqdnFor-$uuid");
} else {
$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();
}
// IMPORTANT: SERVICE_FQDN env vars should NOT contain scheme (host only)
// But $fqdn variable itself may contain scheme (used for database domain field)
// Strip scheme for environment variable values
$fqdnValueForEnv = str($fqdn)->after('://')->value();
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$fqdn = "$fqdn$path";
$url = "$url$path";
$fqdnValueForEnv = "$fqdnValueForEnv$path";
}
}
$fqdnWithPort = $fqdn;
$urlWithPort = $url;
$fqdnValueForEnvWithPort = $fqdnValueForEnv;
if ($fqdn && $port) {
$fqdnWithPort = "$fqdn:$port";
$fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port";
}
if ($url && $port) {
$urlWithPort = "$url:$port";
}
if (is_null($savedService->fqdn)) {
// Save URL (with scheme) to database, not FQDN
if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
if ($fqdnFor) {
$savedService->fqdn = $fqdnWithPort;
}
if ($urlFor) {
$savedService->fqdn = $urlWithPort;
}
$savedService->fqdn = $urlWithPort;
} else {
$savedService->fqdn = $fqdnWithPort;
$savedService->fqdn = $urlWithPort;
}
$savedService->save();
}
if (! $parsed['has_port']) {
// ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port)
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceName}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnv,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceName}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
]);
// For port-specific variables, ALSO create port-specific pairs
// If template variable has port, create both URL and FQDN with port suffix
if ($parsed['has_port'] && $port) {
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'key' => "SERVICE_FQDN_{$serviceName}_{$port}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'value' => $fqdnValueForEnvWithPort,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
]);
}
if ($parsed['has_port']) {
// For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000),
// keep the port suffix in the key and use the URL with port
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnWithPort,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'key' => "SERVICE_URL_{$serviceName}_{$port}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [

View file

@ -115,65 +115,163 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$resource->save();
}
$serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
// Extract SERVICE_URL and SERVICE_FQDN variable names from the compose template
// to ensure we use the exact names defined in the template (which may be abbreviated)
// IMPORTANT: Only extract variables that are DIRECTLY DECLARED for this service,
// not variables that are merely referenced from other services
$serviceConfig = data_get($dockerCompose, "services.{$name}");
$environment = data_get($serviceConfig, 'environment', []);
$templateVariableNames = [];
foreach ($environment as $envVar) {
if (is_string($envVar)) {
// Extract variable name (before '=' if present)
$envVarName = str($envVar)->before('=')->trim();
// Only include if it's a direct declaration (not a reference like ${VAR})
// Direct declarations look like: SERVICE_URL_APP or SERVICE_URL_APP_3000
// References look like: NEXT_PUBLIC_URL=${SERVICE_URL_APP}
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
$templateVariableNames[] = $envVarName->value();
}
}
// DO NOT extract variables that are only referenced with ${VAR_NAME} syntax
// Those belong to other services and will be updated when THOSE services are updated
}
// Remove duplicates
$templateVariableNames = array_unique($templateVariableNames);
// Extract unique service names to process (preserving the original case from template)
// This allows us to create both URL and FQDN pairs regardless of which one is in the template
$serviceNamesToProcess = [];
foreach ($templateVariableNames as $templateVarName) {
$parsed = parseServiceEnvironmentVariable($templateVarName);
// Extract the original service name with case preserved from the template
$strKey = str($templateVarName);
if ($parsed['has_port']) {
// For port-specific variables, get the name between SERVICE_URL_/SERVICE_FQDN_ and the last underscore
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
} else {
continue;
}
} else {
// For base variables, get everything after SERVICE_URL_/SERVICE_FQDN_
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->value();
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
} else {
continue;
}
}
// Use lowercase key for array indexing (to group case variations together)
$serviceKey = str($serviceName)->lower()->value();
// Track both base service name and port-specific variant
if (! isset($serviceNamesToProcess[$serviceKey])) {
$serviceNamesToProcess[$serviceKey] = [
'base' => $serviceName, // Preserve original case
'ports' => [],
];
}
// If this variable has a port, track it
if ($parsed['has_port'] && $parsed['port']) {
$serviceNamesToProcess[$serviceKey]['ports'][] = $parsed['port'];
}
}
// Delete all existing SERVICE_URL and SERVICE_FQDN variables for these service names
// We need to delete both URL and FQDN variants, with and without ports
foreach ($serviceNamesToProcess as $serviceInfo) {
$serviceName = $serviceInfo['base'];
// Delete base variables
$resource->service->environment_variables()->where('key', "SERVICE_URL_{$serviceName}")->delete();
$resource->service->environment_variables()->where('key', "SERVICE_FQDN_{$serviceName}")->delete();
// Delete port-specific variables
foreach ($serviceInfo['ports'] as $port) {
$resource->service->environment_variables()->where('key', "SERVICE_URL_{$serviceName}_{$port}")->delete();
$resource->service->environment_variables()->where('key', "SERVICE_FQDN_{$serviceName}_{$port}")->delete();
}
}
if ($resource->fqdn) {
$resourceFqdns = str($resource->fqdn)->explode(',');
$resourceFqdns = $resourceFqdns->first();
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$url = Url::fromString($resourceFqdns);
$port = $url->getPort();
$path = $url->getPath();
// Prepare URL value (with scheme and host)
$urlValue = $url->getScheme().'://'.$url->getHost();
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
// Prepare FQDN value (host only, no scheme)
$fqdnHost = $url->getHost();
$fqdnValue = str($fqdnHost)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// For each service name found in template, create BOTH SERVICE_URL and SERVICE_FQDN pairs
foreach ($serviceNamesToProcess as $serviceInfo) {
$serviceName = $serviceInfo['base'];
$ports = array_unique($serviceInfo['ports']);
// ALWAYS create base pair (without port)
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
'key' => "SERVICE_URL_{$serviceName}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
}
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
$fqdn = Url::fromString($resourceFqdns);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$fqdn = $fqdn->getHost();
$fqdnValue = str($fqdn)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
'key' => "SERVICE_FQDN_{$serviceName}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
// Create port-specific pairs for each port found in template or FQDN
$allPorts = $ports;
if ($port && ! in_array($port, $allPorts)) {
$allPorts[] = $port;
}
foreach ($allPorts as $portNum) {
$urlWithPort = $urlValue.':'.$portNum;
$fqdnWithPort = $fqdnValue.':'.$portNum;
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => "SERVICE_URL_{$serviceName}_{$portNum}",
], [
'value' => $urlWithPort,
'is_preview' => false,
]);
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => "SERVICE_FQDN_{$serviceName}_{$portNum}",
], [
'value' => $fqdnWithPort,
'is_preview' => false,
]);
}
}
}
} catch (\Throwable $e) {

View file

@ -0,0 +1,190 @@
<?php
/**
* Unit tests to verify that Applications using Docker Compose handle
* SERVICE_URL and SERVICE_FQDN environment variables correctly.
*
* This ensures consistency with Service behavior where BOTH URL and FQDN
* pairs are always created together, regardless of which one is in the template.
*/
it('ensures parsers.php creates both URL and FQDN pairs for applications', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that the fix is in place
expect($parsersFile)->toContain('ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs');
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceName}');
expect($parsersFile)->toContain('SERVICE_URL_{$serviceName}');
});
it('extracts service name with case preservation for applications', function () {
// Simulate what the parser does for applications
$templateVar = 'SERVICE_URL_WORDPRESS';
$strKey = str($templateVar);
$parsed = parseServiceEnvironmentVariable($templateVar);
if ($parsed['has_port']) {
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
} else {
$serviceName = $strKey->after('SERVICE_URL_')->value();
}
expect($serviceName)->toBe('WORDPRESS');
expect($parsed['service_name'])->toBe('wordpress'); // lowercase for internal use
});
it('handles port-specific application service variables', function () {
$templateVar = 'SERVICE_URL_APP_3000';
$strKey = str($templateVar);
$parsed = parseServiceEnvironmentVariable($templateVar);
if ($parsed['has_port']) {
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
} else {
$serviceName = $strKey->after('SERVICE_URL_')->value();
}
expect($serviceName)->toBe('APP');
expect($parsed['port'])->toBe('3000');
expect($parsed['has_port'])->toBeTrue();
});
it('application should create 2 base variables when template has base SERVICE_URL', function () {
// Given: Template defines SERVICE_URL_WP
// Then: Should create both:
// 1. SERVICE_URL_WP
// 2. SERVICE_FQDN_WP
$templateVar = 'SERVICE_URL_WP';
$strKey = str($templateVar);
$parsed = parseServiceEnvironmentVariable($templateVar);
$serviceName = $strKey->after('SERVICE_URL_')->value();
$urlKey = "SERVICE_URL_{$serviceName}";
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
expect($urlKey)->toBe('SERVICE_URL_WP');
expect($fqdnKey)->toBe('SERVICE_FQDN_WP');
expect($parsed['has_port'])->toBeFalse();
});
it('application should create 4 variables when template has port-specific SERVICE_URL', function () {
// Given: Template defines SERVICE_URL_APP_8080
// Then: Should create all 4:
// 1. SERVICE_URL_APP (base)
// 2. SERVICE_FQDN_APP (base)
// 3. SERVICE_URL_APP_8080 (port-specific)
// 4. SERVICE_FQDN_APP_8080 (port-specific)
$templateVar = 'SERVICE_URL_APP_8080';
$strKey = str($templateVar);
$parsed = parseServiceEnvironmentVariable($templateVar);
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
$port = $parsed['port'];
$baseUrlKey = "SERVICE_URL_{$serviceName}";
$baseFqdnKey = "SERVICE_FQDN_{$serviceName}";
$portUrlKey = "SERVICE_URL_{$serviceName}_{$port}";
$portFqdnKey = "SERVICE_FQDN_{$serviceName}_{$port}";
expect($baseUrlKey)->toBe('SERVICE_URL_APP');
expect($baseFqdnKey)->toBe('SERVICE_FQDN_APP');
expect($portUrlKey)->toBe('SERVICE_URL_APP_8080');
expect($portFqdnKey)->toBe('SERVICE_FQDN_APP_8080');
});
it('application should create pairs when template has only SERVICE_FQDN', function () {
// Given: Template defines SERVICE_FQDN_DB
// Then: Should create both:
// 1. SERVICE_FQDN_DB
// 2. SERVICE_URL_DB (created automatically)
$templateVar = 'SERVICE_FQDN_DB';
$strKey = str($templateVar);
$parsed = parseServiceEnvironmentVariable($templateVar);
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
$urlKey = "SERVICE_URL_{$serviceName}";
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
expect($fqdnKey)->toBe('SERVICE_FQDN_DB');
expect($urlKey)->toBe('SERVICE_URL_DB');
expect($parsed['has_port'])->toBeFalse();
});
it('verifies application deletion nulls both URL and FQDN', function () {
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that deletion handles both types
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceNameFormatted}');
expect($parsersFile)->toContain('SERVICE_URL_{$serviceNameFormatted}');
// Both should be set to null when domain is empty
expect($parsersFile)->toContain('\'value\' => null');
});
it('handles abbreviated service names in applications', function () {
// Applications can have abbreviated names in compose files just like services
$templateVar = 'SERVICE_URL_WP'; // WordPress abbreviated
$strKey = str($templateVar);
$serviceName = $strKey->after('SERVICE_URL_')->value();
expect($serviceName)->toBe('WP');
expect($serviceName)->not->toBe('WORDPRESS');
});
it('application compose parsing creates pairs regardless of template type', function () {
// Test that whether template uses SERVICE_URL or SERVICE_FQDN,
// the parser creates both
$testCases = [
'SERVICE_URL_APP' => ['base' => 'APP', 'port' => null],
'SERVICE_FQDN_APP' => ['base' => 'APP', 'port' => null],
'SERVICE_URL_APP_3000' => ['base' => 'APP', 'port' => '3000'],
'SERVICE_FQDN_APP_3000' => ['base' => 'APP', 'port' => '3000'],
];
foreach ($testCases as $templateVar => $expected) {
$strKey = str($templateVar);
$parsed = parseServiceEnvironmentVariable($templateVar);
if ($parsed['has_port']) {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
} else {
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
}
} else {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->value();
} else {
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
}
}
expect($serviceName)->toBe($expected['base'], "Failed for $templateVar");
expect($parsed['port'])->toBe($expected['port'], "Port mismatch for $templateVar");
}
});
it('verifies both application and service use same logic', function () {
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Both should have the same pattern of creating pairs
expect($servicesFile)->toContain('ALWAYS create base pair');
expect($parsersFile)->toContain('ALWAYS create BOTH');
// Both should create SERVICE_URL_
expect($servicesFile)->toContain('SERVICE_URL_{$serviceName}');
expect($parsersFile)->toContain('SERVICE_URL_{$serviceName}');
// Both should create SERVICE_FQDN_
expect($servicesFile)->toContain('SERVICE_FQDN_{$serviceName}');
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceName}');
});

View file

@ -0,0 +1,401 @@
<?php
/**
* Unit tests to verify that updateCompose() correctly handles abbreviated
* SERVICE_URL and SERVICE_FQDN variable names from templates.
*
* This tests the fix for GitHub issue #7243 where SERVICE_URL_OPDASHBOARD
* wasn't being updated when the domain changed, while SERVICE_URL_OPDASHBOARD_3000
* was being updated correctly.
*
* The issue occurs when template variable names are abbreviated (e.g., OPDASHBOARD)
* instead of using the full container name (e.g., OPENPANEL_DASHBOARD).
*/
use Symfony\Component\Yaml\Yaml;
it('detects SERVICE_URL variables directly declared in template environment', function () {
$yaml = <<<'YAML'
services:
openpanel-dashboard:
environment:
- SERVICE_URL_OPDASHBOARD_3000
- OTHER_VAR=value
YAML;
$dockerCompose = Yaml::parse($yaml);
$serviceConfig = data_get($dockerCompose, 'services.openpanel-dashboard');
$environment = data_get($serviceConfig, 'environment', []);
$templateVariableNames = [];
foreach ($environment as $envVar) {
if (is_string($envVar)) {
$envVarName = str($envVar)->before('=')->trim();
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
$templateVariableNames[] = $envVarName->value();
}
}
}
expect($templateVariableNames)->toContain('SERVICE_URL_OPDASHBOARD_3000');
expect($templateVariableNames)->not->toContain('OTHER_VAR');
});
it('only detects directly declared SERVICE_URL variables not references', function () {
$yaml = <<<'YAML'
services:
openpanel-dashboard:
environment:
- SERVICE_URL_OPDASHBOARD_3000
- NEXT_PUBLIC_DASHBOARD_URL=${SERVICE_URL_OPDASHBOARD}
- NEXT_PUBLIC_API_URL=${SERVICE_URL_OPAPI}
YAML;
$dockerCompose = Yaml::parse($yaml);
$serviceConfig = data_get($dockerCompose, 'services.openpanel-dashboard');
$environment = data_get($serviceConfig, 'environment', []);
$templateVariableNames = [];
foreach ($environment as $envVar) {
if (is_string($envVar)) {
$envVarName = str($envVar)->before('=')->trim();
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
$templateVariableNames[] = $envVarName->value();
}
}
}
// Should only detect the direct declaration
expect($templateVariableNames)->toContain('SERVICE_URL_OPDASHBOARD_3000');
// Should NOT detect references (those belong to other services)
expect($templateVariableNames)->not->toContain('SERVICE_URL_OPDASHBOARD');
expect($templateVariableNames)->not->toContain('SERVICE_URL_OPAPI');
});
it('detects multiple directly declared SERVICE_URL variables', function () {
$yaml = <<<'YAML'
services:
app:
environment:
- SERVICE_URL_APP
- SERVICE_URL_APP_3000
- SERVICE_FQDN_API
YAML;
$dockerCompose = Yaml::parse($yaml);
$serviceConfig = data_get($dockerCompose, 'services.app');
$environment = data_get($serviceConfig, 'environment', []);
$templateVariableNames = [];
foreach ($environment as $envVar) {
if (is_string($envVar)) {
// Extract variable name (before '=' if present)
$envVarName = str($envVar)->before('=')->trim();
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
$templateVariableNames[] = $envVarName->value();
}
}
}
$templateVariableNames = array_unique($templateVariableNames);
expect($templateVariableNames)->toHaveCount(3);
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000');
expect($templateVariableNames)->toContain('SERVICE_FQDN_API');
});
it('removes duplicates from template variable names', function () {
$yaml = <<<'YAML'
services:
app:
environment:
- SERVICE_URL_APP
- PUBLIC_URL=${SERVICE_URL_APP}
- PRIVATE_URL=${SERVICE_URL_APP}
YAML;
$dockerCompose = Yaml::parse($yaml);
$serviceConfig = data_get($dockerCompose, 'services.app');
$environment = data_get($serviceConfig, 'environment', []);
$templateVariableNames = [];
foreach ($environment as $envVar) {
if (is_string($envVar)) {
$envVarName = str($envVar)->before('=')->trim();
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
$templateVariableNames[] = $envVarName->value();
}
}
if (is_string($envVar) && str($envVar)->contains('${')) {
preg_match_all('/\$\{(SERVICE_(?:FQDN|URL)_[^}]+)\}/', $envVar, $matches);
if (! empty($matches[1])) {
foreach ($matches[1] as $match) {
$templateVariableNames[] = $match;
}
}
}
}
$templateVariableNames = array_unique($templateVariableNames);
// SERVICE_URL_APP appears 3 times but should only be in array once
expect($templateVariableNames)->toHaveCount(1);
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
});
it('detects SERVICE_FQDN variables in addition to SERVICE_URL', function () {
$yaml = <<<'YAML'
services:
app:
environment:
- SERVICE_FQDN_APP
- SERVICE_FQDN_APP_3000
- SERVICE_URL_APP
- SERVICE_URL_APP_8080
YAML;
$dockerCompose = Yaml::parse($yaml);
$serviceConfig = data_get($dockerCompose, 'services.app');
$environment = data_get($serviceConfig, 'environment', []);
$templateVariableNames = [];
foreach ($environment as $envVar) {
if (is_string($envVar)) {
$envVarName = str($envVar)->before('=')->trim();
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
$templateVariableNames[] = $envVarName->value();
}
}
}
expect($templateVariableNames)->toHaveCount(4);
expect($templateVariableNames)->toContain('SERVICE_FQDN_APP');
expect($templateVariableNames)->toContain('SERVICE_FQDN_APP_3000');
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
expect($templateVariableNames)->toContain('SERVICE_URL_APP_8080');
});
it('handles abbreviated service names that differ from container names', function () {
// This is the actual OpenPanel case from GitHub issue #7243
// Container name: openpanel-dashboard
// Template variable: SERVICE_URL_OPDASHBOARD (abbreviated)
$containerName = 'openpanel-dashboard';
$templateVariableName = 'SERVICE_URL_OPDASHBOARD';
// The old logic would generate this from container name:
$generatedFromContainer = 'SERVICE_URL_'.str($containerName)->upper()->replace('-', '_')->value();
// This shows the mismatch
expect($generatedFromContainer)->toBe('SERVICE_URL_OPENPANEL_DASHBOARD');
expect($generatedFromContainer)->not->toBe($templateVariableName);
// The template uses the abbreviated form
expect($templateVariableName)->toBe('SERVICE_URL_OPDASHBOARD');
});
it('correctly identifies abbreviated variable patterns', function () {
$tests = [
// Full name transformations (old logic)
['container' => 'openpanel-dashboard', 'generated' => 'SERVICE_URL_OPENPANEL_DASHBOARD'],
['container' => 'my-long-service', 'generated' => 'SERVICE_URL_MY_LONG_SERVICE'],
// Abbreviated forms (template logic)
['container' => 'openpanel-dashboard', 'template' => 'SERVICE_URL_OPDASHBOARD'],
['container' => 'openpanel-api', 'template' => 'SERVICE_URL_OPAPI'],
['container' => 'my-long-service', 'template' => 'SERVICE_URL_MLS'],
];
foreach ($tests as $test) {
if (isset($test['generated'])) {
$generated = 'SERVICE_URL_'.str($test['container'])->upper()->replace('-', '_')->value();
expect($generated)->toBe($test['generated']);
}
if (isset($test['template'])) {
// Template abbreviations can't be generated from container name
// They must be parsed from the actual template
expect($test['template'])->toMatch('/^SERVICE_URL_[A-Z0-9_]+$/');
}
}
});
it('verifies direct declarations are not confused with references', function () {
// Direct declarations should be detected
$directDeclaration = 'SERVICE_URL_APP';
expect(str($directDeclaration)->startsWith('SERVICE_URL_'))->toBeTrue();
expect(str($directDeclaration)->before('=')->value())->toBe('SERVICE_URL_APP');
// References should not be detected as declarations
$reference = 'NEXT_PUBLIC_URL=${SERVICE_URL_APP}';
$varName = str($reference)->before('=')->trim();
expect($varName->startsWith('SERVICE_URL_'))->toBeFalse();
expect($varName->value())->toBe('NEXT_PUBLIC_URL');
});
it('ensures updateCompose helper file has template parsing logic', function () {
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
// Check that the fix is in place
expect($servicesFile)->toContain('Extract SERVICE_URL and SERVICE_FQDN variable names from the compose template');
expect($servicesFile)->toContain('to ensure we use the exact names defined in the template');
expect($servicesFile)->toContain('$templateVariableNames');
expect($servicesFile)->toContain('DIRECTLY DECLARED');
expect($servicesFile)->toContain('not variables that are merely referenced from other services');
});
it('verifies that service names are extracted to create both URL and FQDN pairs', function () {
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
// Verify the logic to create both pairs exists
expect($servicesFile)->toContain('create BOTH SERVICE_URL and SERVICE_FQDN pairs');
expect($servicesFile)->toContain('ALWAYS create base pair');
expect($servicesFile)->toContain('SERVICE_URL_{$serviceName}');
expect($servicesFile)->toContain('SERVICE_FQDN_{$serviceName}');
});
it('extracts service names correctly for pairing', function () {
// Simulate what the updateCompose function does
$templateVariableNames = [
'SERVICE_URL_OPDASHBOARD',
'SERVICE_URL_OPDASHBOARD_3000',
'SERVICE_URL_OPAPI',
];
$serviceNamesToProcess = [];
foreach ($templateVariableNames as $templateVarName) {
$parsed = parseServiceEnvironmentVariable($templateVarName);
$serviceName = $parsed['service_name'];
if (! isset($serviceNamesToProcess[$serviceName])) {
$serviceNamesToProcess[$serviceName] = [
'base' => $serviceName,
'ports' => [],
];
}
if ($parsed['has_port'] && $parsed['port']) {
$serviceNamesToProcess[$serviceName]['ports'][] = $parsed['port'];
}
}
// Should extract 2 unique service names
expect($serviceNamesToProcess)->toHaveCount(2);
expect($serviceNamesToProcess)->toHaveKey('opdashboard');
expect($serviceNamesToProcess)->toHaveKey('opapi');
// OPDASHBOARD should have port 3000 tracked
expect($serviceNamesToProcess['opdashboard']['ports'])->toContain('3000');
// OPAPI should have no ports
expect($serviceNamesToProcess['opapi']['ports'])->toBeEmpty();
});
it('should create both URL and FQDN when only URL is in template', function () {
// Given: Template defines only SERVICE_URL_APP
$templateVar = 'SERVICE_URL_APP';
// When: Processing this variable
$parsed = parseServiceEnvironmentVariable($templateVar);
$serviceName = $parsed['service_name'];
// Then: We should create both:
// - SERVICE_URL_APP (or SERVICE_URL_app depending on template)
// - SERVICE_FQDN_APP (or SERVICE_FQDN_app depending on template)
expect($serviceName)->toBe('app');
$urlKey = 'SERVICE_URL_'.str($serviceName)->upper();
$fqdnKey = 'SERVICE_FQDN_'.str($serviceName)->upper();
expect($urlKey)->toBe('SERVICE_URL_APP');
expect($fqdnKey)->toBe('SERVICE_FQDN_APP');
});
it('should create both URL and FQDN when only FQDN is in template', function () {
// Given: Template defines only SERVICE_FQDN_DATABASE
$templateVar = 'SERVICE_FQDN_DATABASE';
// When: Processing this variable
$parsed = parseServiceEnvironmentVariable($templateVar);
$serviceName = $parsed['service_name'];
// Then: We should create both:
// - SERVICE_URL_DATABASE (or SERVICE_URL_database depending on template)
// - SERVICE_FQDN_DATABASE (or SERVICE_FQDN_database depending on template)
expect($serviceName)->toBe('database');
$urlKey = 'SERVICE_URL_'.str($serviceName)->upper();
$fqdnKey = 'SERVICE_FQDN_'.str($serviceName)->upper();
expect($urlKey)->toBe('SERVICE_URL_DATABASE');
expect($fqdnKey)->toBe('SERVICE_FQDN_DATABASE');
});
it('should create all 4 variables when port-specific variable is in template', function () {
// Given: Template defines SERVICE_URL_UMAMI_3000
$templateVar = 'SERVICE_URL_UMAMI_3000';
// When: Processing this variable
$parsed = parseServiceEnvironmentVariable($templateVar);
$serviceName = $parsed['service_name'];
$port = $parsed['port'];
// Then: We should create all 4:
// 1. SERVICE_URL_UMAMI (base)
// 2. SERVICE_FQDN_UMAMI (base)
// 3. SERVICE_URL_UMAMI_3000 (port-specific)
// 4. SERVICE_FQDN_UMAMI_3000 (port-specific)
expect($serviceName)->toBe('umami');
expect($port)->toBe('3000');
$serviceNameUpper = str($serviceName)->upper();
$baseUrlKey = "SERVICE_URL_{$serviceNameUpper}";
$baseFqdnKey = "SERVICE_FQDN_{$serviceNameUpper}";
$portUrlKey = "SERVICE_URL_{$serviceNameUpper}_{$port}";
$portFqdnKey = "SERVICE_FQDN_{$serviceNameUpper}_{$port}";
expect($baseUrlKey)->toBe('SERVICE_URL_UMAMI');
expect($baseFqdnKey)->toBe('SERVICE_FQDN_UMAMI');
expect($portUrlKey)->toBe('SERVICE_URL_UMAMI_3000');
expect($portFqdnKey)->toBe('SERVICE_FQDN_UMAMI_3000');
});
it('should handle multiple ports for same service', function () {
$templateVariableNames = [
'SERVICE_URL_API_3000',
'SERVICE_URL_API_8080',
];
$serviceNamesToProcess = [];
foreach ($templateVariableNames as $templateVarName) {
$parsed = parseServiceEnvironmentVariable($templateVarName);
$serviceName = $parsed['service_name'];
if (! isset($serviceNamesToProcess[$serviceName])) {
$serviceNamesToProcess[$serviceName] = [
'base' => $serviceName,
'ports' => [],
];
}
if ($parsed['has_port'] && $parsed['port']) {
$serviceNamesToProcess[$serviceName]['ports'][] = $parsed['port'];
}
}
// Should have one service with two ports
expect($serviceNamesToProcess)->toHaveCount(1);
expect($serviceNamesToProcess['api']['ports'])->toHaveCount(2);
expect($serviceNamesToProcess['api']['ports'])->toContain('3000');
expect($serviceNamesToProcess['api']['ports'])->toContain('8080');
// Should create 6 variables total:
// 1. SERVICE_URL_API (base)
// 2. SERVICE_FQDN_API (base)
// 3. SERVICE_URL_API_3000
// 4. SERVICE_FQDN_API_3000
// 5. SERVICE_URL_API_8080
// 6. SERVICE_FQDN_API_8080
});