From 56f32d0f87609d48c4f9f8d766c96c183bcd60f9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:26:11 +0100 Subject: [PATCH 1/7] fix: properly handle SERVICE_URL and SERVICE_FQDN for abbreviated service names (#7243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Models/ServiceApplication.php | 86 ++-- bootstrap/helpers/parsers.php | 266 ++++++------ bootstrap/helpers/services.php | 170 ++++++-- ...icationServiceEnvironmentVariablesTest.php | 190 +++++++++ .../UpdateComposeAbbreviatedVariablesTest.php | 401 ++++++++++++++++++ 5 files changed, 909 insertions(+), 204 deletions(-) create mode 100644 tests/Unit/ApplicationServiceEnvironmentVariablesTest.php create mode 100644 tests/Unit/UpdateComposeAbbreviatedVariablesTest.php diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index fd5c4afdb..e457dbccd 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -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; } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 7012e2087..6a75adb96 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -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, ], [ diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index a6d427a6b..fdbaf6364 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -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) { diff --git a/tests/Unit/ApplicationServiceEnvironmentVariablesTest.php b/tests/Unit/ApplicationServiceEnvironmentVariablesTest.php new file mode 100644 index 000000000..fe1a89443 --- /dev/null +++ b/tests/Unit/ApplicationServiceEnvironmentVariablesTest.php @@ -0,0 +1,190 @@ +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}'); +}); diff --git a/tests/Unit/UpdateComposeAbbreviatedVariablesTest.php b/tests/Unit/UpdateComposeAbbreviatedVariablesTest.php new file mode 100644 index 000000000..50fe2b6b1 --- /dev/null +++ b/tests/Unit/UpdateComposeAbbreviatedVariablesTest.php @@ -0,0 +1,401 @@ +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 +}); From a5ce1db8715d62b437cb3104af5ca6427f28a47b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:21:35 +0100 Subject: [PATCH 2/7] fix: handle map-style environment variables in updateCompose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The updateCompose() function now correctly detects SERVICE_URL_* and SERVICE_FQDN_* variables regardless of whether they are defined in YAML list-style or map-style format. Previously, the code only worked with list-style environment definitions: ```yaml environment: - SERVICE_URL_APP_3000 ``` Now it also handles map-style definitions: ```yaml environment: SERVICE_URL_TRIGGER_3000: "" SERVICE_FQDN_DB: localhost ``` The fix distinguishes between the two formats by checking if the array key is numeric (list-style) or a string (map-style), then extracts the variable name from the appropriate location. Added 5 comprehensive unit tests covering: - Map-style environment format detection - Multiple map-style variables - References vs declarations in map-style - Abbreviated service names with map-style - Verification of dual-format handling This fixes variable detection for service templates like trigger.yaml, langfuse.yaml, and paymenter.yaml that use map-style format. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bootstrap/helpers/services.php | 13 +- .../UpdateComposeAbbreviatedVariablesTest.php | 162 ++++++++++++++++++ 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index fdbaf6364..3fff2c090 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -123,16 +123,23 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) $environment = data_get($serviceConfig, 'environment', []); $templateVariableNames = []; - foreach ($environment as $envVar) { - if (is_string($envVar)) { + foreach ($environment as $key => $value) { + if (is_int($key) && is_string($value)) { + // List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value" // Extract variable name (before '=' if present) - $envVarName = str($envVar)->before('=')->trim(); + $envVarName = str($value)->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(); } + } elseif (is_string($key)) { + // Map-style: "SERVICE_URL_APP_3000: value" or "SERVICE_FQDN_DB: localhost" + $envVarName = str($key); + 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 diff --git a/tests/Unit/UpdateComposeAbbreviatedVariablesTest.php b/tests/Unit/UpdateComposeAbbreviatedVariablesTest.php index 50fe2b6b1..4ef7def9c 100644 --- a/tests/Unit/UpdateComposeAbbreviatedVariablesTest.php +++ b/tests/Unit/UpdateComposeAbbreviatedVariablesTest.php @@ -399,3 +399,165 @@ // 5. SERVICE_URL_API_8080 // 6. SERVICE_FQDN_API_8080 }); + +it('detects SERVICE_URL variables in map-style environment format', function () { + $yaml = <<<'YAML' +services: + trigger: + environment: + SERVICE_URL_TRIGGER_3000: "" + SERVICE_FQDN_DB: localhost + OTHER_VAR: value +YAML; + + $dockerCompose = Yaml::parse($yaml); + $serviceConfig = data_get($dockerCompose, 'services.trigger'); + $environment = data_get($serviceConfig, 'environment', []); + + $templateVariableNames = []; + foreach ($environment as $key => $value) { + if (is_int($key) && is_string($value)) { + // List-style + $envVarName = str($value)->before('=')->trim(); + if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $templateVariableNames[] = $envVarName->value(); + } + } elseif (is_string($key)) { + // Map-style + $envVarName = str($key); + if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $templateVariableNames[] = $envVarName->value(); + } + } + } + + expect($templateVariableNames)->toHaveCount(2); + expect($templateVariableNames)->toContain('SERVICE_URL_TRIGGER_3000'); + expect($templateVariableNames)->toContain('SERVICE_FQDN_DB'); + expect($templateVariableNames)->not->toContain('OTHER_VAR'); +}); + +it('handles multiple map-style SERVICE_URL and SERVICE_FQDN variables', function () { + $yaml = <<<'YAML' +services: + app: + environment: + SERVICE_URL_APP_3000: "" + SERVICE_FQDN_API: api.local + SERVICE_URL_WEB: "" + OTHER_VAR: value +YAML; + + $dockerCompose = Yaml::parse($yaml); + $serviceConfig = data_get($dockerCompose, 'services.app'); + $environment = data_get($serviceConfig, 'environment', []); + + $templateVariableNames = []; + foreach ($environment as $key => $value) { + if (is_int($key) && is_string($value)) { + // List-style + $envVarName = str($value)->before('=')->trim(); + if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $templateVariableNames[] = $envVarName->value(); + } + } elseif (is_string($key)) { + // Map-style + $envVarName = str($key); + if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $templateVariableNames[] = $envVarName->value(); + } + } + } + + expect($templateVariableNames)->toHaveCount(3); + expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000'); + expect($templateVariableNames)->toContain('SERVICE_FQDN_API'); + expect($templateVariableNames)->toContain('SERVICE_URL_WEB'); + expect($templateVariableNames)->not->toContain('OTHER_VAR'); +}); + +it('does not detect SERVICE_URL references in map-style values', function () { + $yaml = <<<'YAML' +services: + app: + environment: + SERVICE_URL_APP_3000: "" + NEXT_PUBLIC_URL: ${SERVICE_URL_APP} + API_ENDPOINT: ${SERVICE_URL_API} +YAML; + + $dockerCompose = Yaml::parse($yaml); + $serviceConfig = data_get($dockerCompose, 'services.app'); + $environment = data_get($serviceConfig, 'environment', []); + + $templateVariableNames = []; + foreach ($environment as $key => $value) { + if (is_int($key) && is_string($value)) { + // List-style + $envVarName = str($value)->before('=')->trim(); + if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $templateVariableNames[] = $envVarName->value(); + } + } elseif (is_string($key)) { + // Map-style + $envVarName = str($key); + if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $templateVariableNames[] = $envVarName->value(); + } + } + } + + // Should only detect the direct declaration, not references in values + expect($templateVariableNames)->toHaveCount(1); + expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000'); + expect($templateVariableNames)->not->toContain('SERVICE_URL_APP'); + expect($templateVariableNames)->not->toContain('SERVICE_URL_API'); + expect($templateVariableNames)->not->toContain('NEXT_PUBLIC_URL'); + expect($templateVariableNames)->not->toContain('API_ENDPOINT'); +}); + +it('handles map-style with abbreviated service names', function () { + // Simulating the langfuse.yaml case with map-style + $yaml = <<<'YAML' +services: + langfuse: + environment: + SERVICE_URL_LANGFUSE_3000: ${SERVICE_URL_LANGFUSE_3000} + DATABASE_URL: postgres://... +YAML; + + $dockerCompose = Yaml::parse($yaml); + $serviceConfig = data_get($dockerCompose, 'services.langfuse'); + $environment = data_get($serviceConfig, 'environment', []); + + $templateVariableNames = []; + foreach ($environment as $key => $value) { + if (is_int($key) && is_string($value)) { + // List-style + $envVarName = str($value)->before('=')->trim(); + if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $templateVariableNames[] = $envVarName->value(); + } + } elseif (is_string($key)) { + // Map-style + $envVarName = str($key); + if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $templateVariableNames[] = $envVarName->value(); + } + } + } + + expect($templateVariableNames)->toHaveCount(1); + expect($templateVariableNames)->toContain('SERVICE_URL_LANGFUSE_3000'); + expect($templateVariableNames)->not->toContain('DATABASE_URL'); +}); + +it('verifies updateCompose helper has dual-format handling', function () { + $servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php'); + + // Check that both formats are handled + expect($servicesFile)->toContain('is_int($key) && is_string($value)'); + expect($servicesFile)->toContain('List-style'); + expect($servicesFile)->toContain('elseif (is_string($key))'); + expect($servicesFile)->toContain('Map-style'); +}); From eefcb6fc35e6555dc4c042285cb47d6f5c7e2844 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:04:41 +0100 Subject: [PATCH 3/7] fix: clean up formatting and indentation in global-search.blade.php --- .../views/livewire/global-search.blade.php | 275 ++++++++---------- 1 file changed, 115 insertions(+), 160 deletions(-) diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 7a9868c06..01d3131f6 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -254,7 +254,8 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[10vh]">
-
- + @@ -311,8 +311,8 @@ class="mt-2 bg-white dark:bg-coolgray-100 rounded-lg shadow-xl ring-1 ring-neutr class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"> - +
@@ -327,13 +327,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@if ($loadingServers) -
- - +
+ + @@ -343,8 +341,7 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
@elseif (count($availableServers) > 0) @foreach ($availableServers as $index => $server) - @@ -388,10 +384,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
@@ -406,13 +402,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@if ($loadingDestinations) -
- - +
+ + @@ -422,25 +416,22 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
@elseif (count($availableDestinations) > 0) @foreach ($availableDestinations as $index => $destination) - @@ -462,10 +453,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
@@ -480,13 +471,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@if ($loadingProjects) -
- - +
+ + @@ -496,18 +485,15 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
@elseif (count($availableProjects) > 0) @foreach ($availableProjects as $index => $project) - @@ -542,10 +528,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
@@ -560,13 +546,11 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@if ($loadingEnvironments) -
- - +
+ + @@ -576,18 +560,15 @@ class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg
@elseif (count($availableEnvironments) > 0) @foreach ($availableEnvironments as $index => $environment) - @@ -639,8 +620,7 @@ class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-cool
- + {{ $result['name'] }} +
{{ $result['project'] }} / {{ $result['environment'] }}
@endif @if (!empty($result['description'])) -
+
{{ Str::limit($result['description'], 80) }}
@endif @@ -677,8 +655,8 @@ class="text-sm text-neutral-600 dark:text-neutral-400"> - +
@@ -708,16 +686,15 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 da
- + class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none" + viewBox="0 0 24 24" stroke="currentColor"> +
-
+
{{ $item['name'] }}
@if (isset($item['quickcommand'])) @@ -725,8 +702,7 @@ class="font-medium text-neutral-900 dark:text-white truncate"> class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickcommand'] }} @endif
-
+
{{ $item['description'] }}
@@ -734,8 +710,8 @@ class="text-sm text-neutral-600 dark:text-neutral-400 truncate"> - +
@@ -820,8 +796,7 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 da class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center"> + fill="none" viewBox="0 0 24 24" stroke="currentColor"> @@ -869,14 +844,6 @@ class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"

💡 Tip: Search for service names like "wordpress", "postgres", or "redis"

-
@@ -897,12 +864,10 @@ class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center" if (firstInput) firstInput.focus(); }, 200); } - })" - class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> -
+
New Project @@ -939,12 +904,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 if (firstInput) firstInput.focus(); }, 200); } - })" - class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> -
+
New Server @@ -981,12 +944,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 if (firstInput) firstInput.focus(); }, 200); } - })" - class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> -
+
New Team @@ -1023,12 +984,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 if (firstInput) firstInput.focus(); }, 200); } - })" - class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> -
+
New S3 Storage @@ -1065,12 +1024,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 if (firstInput) firstInput.focus(); }, 200); } - })" - class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> -
+
New Private Key @@ -1107,12 +1064,10 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 if (firstInput) firstInput.focus(); }, 200); } - })" - class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> -
+
New GitHub App @@ -1139,4 +1094,4 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
-
+
\ No newline at end of file From 85b73a8c005fa42d5cc3ffaf654f2036cf7e97c1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:25:25 +0100 Subject: [PATCH 4/7] fix: initialize Collection properties to handle queue deserialization edge cases --- app/Jobs/PushServerUpdateJob.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9f81155be..b3a0f3590 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -105,6 +105,20 @@ public function __construct(public Server $server, public $data) public function handle() { + // Defensive initialization for Collection properties to handle queue deserialization edge cases + $this->serviceContainerStatuses ??= collect(); + $this->applicationContainerStatuses ??= collect(); + $this->foundApplicationIds ??= collect(); + $this->foundDatabaseUuids ??= collect(); + $this->foundServiceApplicationIds ??= collect(); + $this->foundApplicationPreviewsIds ??= collect(); + $this->foundServiceDatabaseIds ??= collect(); + $this->allApplicationIds ??= collect(); + $this->allDatabaseUuids ??= collect(); + $this->allTcpProxyUuids ??= collect(); + $this->allServiceApplicationIds ??= collect(); + $this->allServiceDatabaseIds ??= collect(); + // TODO: Swarm is not supported yet if (! $this->data) { throw new \Exception('No data provided'); From 2edf2338de07cff658c63a881ef39bb787327b6c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:41:25 +0100 Subject: [PATCH 5/7] fix: enhance getRequiredPort to support map-style environment variables for SERVICE_URL and SERVICE_FQDN --- app/Models/ServiceApplication.php | 21 ++++- tests/Unit/ServiceRequiredPortTest.php | 117 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index e457dbccd..aef74b402 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -208,10 +208,25 @@ public function getRequiredPort(): ?int // 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)) { + foreach ($environment as $key => $value) { + if (is_int($key) && is_string($value)) { + // List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value" // Extract variable name (before '=' if present) - $envVarName = str($envVar)->before('=')->trim(); + $envVarName = str($value)->before('=')->trim(); + + // 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; + } + } + } elseif (is_string($key)) { + // Map-style: "SERVICE_URL_APP_3000: value" or "SERVICE_FQDN_DB: localhost" + $envVarName = str($key); // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php index 70bf2bca2..2ad345c44 100644 --- a/tests/Unit/ServiceRequiredPortTest.php +++ b/tests/Unit/ServiceRequiredPortTest.php @@ -151,3 +151,120 @@ expect($result)->toBeFalse(); }); + +it('detects port from map-style SERVICE_URL environment variable', function () { + $yaml = <<<'YAML' +services: + trigger: + environment: + SERVICE_URL_TRIGGER_3000: "" + OTHER_VAR: value +YAML; + + $service = Mockery::mock(Service::class)->makePartial(); + $service->docker_compose_raw = $yaml; + $service->shouldReceive('getRequiredPort')->andReturn(null); + + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->name = 'trigger'; + $app->shouldReceive('getAttribute')->with('service')->andReturn($service); + $app->service = $service; + + // Call the actual getRequiredPort method + $result = $app->getRequiredPort(); + + expect($result)->toBe(3000); +}); + +it('detects port from map-style SERVICE_FQDN environment variable', function () { + $yaml = <<<'YAML' +services: + langfuse: + environment: + SERVICE_FQDN_LANGFUSE_3000: localhost + DATABASE_URL: postgres://... +YAML; + + $service = Mockery::mock(Service::class)->makePartial(); + $service->docker_compose_raw = $yaml; + $service->shouldReceive('getRequiredPort')->andReturn(null); + + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->name = 'langfuse'; + $app->shouldReceive('getAttribute')->with('service')->andReturn($service); + $app->service = $service; + + $result = $app->getRequiredPort(); + + expect($result)->toBe(3000); +}); + +it('returns null for map-style environment without port', function () { + $yaml = <<<'YAML' +services: + db: + environment: + SERVICE_FQDN_DB: localhost + SERVICE_URL_DB: http://localhost +YAML; + + $service = Mockery::mock(Service::class)->makePartial(); + $service->docker_compose_raw = $yaml; + $service->shouldReceive('getRequiredPort')->andReturn(null); + + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->name = 'db'; + $app->shouldReceive('getAttribute')->with('service')->andReturn($service); + $app->service = $service; + + $result = $app->getRequiredPort(); + + expect($result)->toBeNull(); +}); + +it('handles list-style environment with port', function () { + $yaml = <<<'YAML' +services: + umami: + environment: + - SERVICE_URL_UMAMI_3000 + - DATABASE_URL=postgres://db/umami +YAML; + + $service = Mockery::mock(Service::class)->makePartial(); + $service->docker_compose_raw = $yaml; + $service->shouldReceive('getRequiredPort')->andReturn(null); + + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->name = 'umami'; + $app->shouldReceive('getAttribute')->with('service')->andReturn($service); + $app->service = $service; + + $result = $app->getRequiredPort(); + + expect($result)->toBe(3000); +}); + +it('prioritizes first port found in environment', function () { + $yaml = <<<'YAML' +services: + multi: + environment: + SERVICE_URL_MULTI_3000: "" + SERVICE_URL_MULTI_8080: "" +YAML; + + $service = Mockery::mock(Service::class)->makePartial(); + $service->docker_compose_raw = $yaml; + $service->shouldReceive('getRequiredPort')->andReturn(null); + + $app = Mockery::mock(ServiceApplication::class)->makePartial(); + $app->name = 'multi'; + $app->shouldReceive('getAttribute')->with('service')->andReturn($service); + $app->service = $service; + + $result = $app->getRequiredPort(); + + // Should return one of the ports (depends on array iteration order) + expect($result)->toBeIn([3000, 8080]); +}); From 64cbda01406a7a45585a8725aa42d1f25af81945 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 24 Nov 2025 08:45:42 +0100 Subject: [PATCH 6/7] fix: remove dead conditional and unused variables in parsers.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove useless conditional check for hyphens in service name normalization The conditional `if (str($serviceName)->contains('-'))` never executes because $serviceName is already normalized with underscores from parseServiceEnvironmentVariable() - Always normalize service names explicitly to match docker_compose_domains lookup This makes the code clearer and more maintainable - Remove unused $fqdnWithPort variable assignments in both applicationParser and serviceParser The variable is calculated but never used - only $urlWithPort and $fqdnValueForEnvWithPort are needed These changes are code cleanup only - no behavior changes or breaking changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bootstrap/helpers/parsers.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index ba1797dd8..8286b643a 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -537,9 +537,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } $originalServiceName = str($serviceName)->replace('_', '-'); - if (str($serviceName)->contains('-')) { - $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_'); - } + // Always normalize service names to match docker_compose_domains lookup + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_'); // Generate BOTH FQDN & URL $fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version); @@ -552,11 +551,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int // Append port if specified $urlWithPort = $url; - $fqdnWithPort = $fqdn; $fqdnValueForEnvWithPort = $fqdnValueForEnv; if ($port && is_numeric($port)) { $urlWithPort = "$url:$port"; - $fqdnWithPort = "$fqdn:$port"; $fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port"; } @@ -1653,11 +1650,9 @@ function serviceParser(Service $resource): Collection } } - $fqdnWithPort = $fqdn; $urlWithPort = $url; $fqdnValueForEnvWithPort = $fqdnValueForEnv; if ($fqdn && $port) { - $fqdnWithPort = "$fqdn:$port"; $fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port"; } if ($url && $port) { From 75381af7422cd74c07af29b2cfdb2153baf0f887 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:22:27 +0100 Subject: [PATCH 7/7] fix: convert Stringable to plain strings in applicationParser for strict comparisons and collection lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes critical bugs where Stringable objects were used in strict comparisons and collection key lookups, causing service existence checks and domain lookups to fail. **Changes:** - Line 539: Added ->value() to $originalServiceName conversion - Line 541: Added ->value() to $serviceName normalization - Line 621: Removed redundant (string) cast now that $serviceName is a plain string **Impact:** - Service existence check now works correctly (line 606: $transformedServiceName === $serviceName) - Domain lookup finds existing domains (line 615: $domains->get($serviceName)) - Prevents duplicate domain entries in docker_compose_domains collection **Tests:** - Added comprehensive unit test suite in ApplicationParserStringableTest.php - 9 test cases covering type verification, strict comparisons, collection operations, and edge cases - All tests pass (24 tests, 153 assertions across related parser tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bootstrap/helpers/parsers.php | 6 +- .../Unit/ApplicationParserStringableTest.php | 182 ++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/ApplicationParserStringableTest.php diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 8286b643a..dfcc3e190 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -536,9 +536,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } - $originalServiceName = str($serviceName)->replace('_', '-'); + $originalServiceName = str($serviceName)->replace('_', '-')->value(); // Always normalize service names to match docker_compose_domains lookup - $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_'); + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); // Generate BOTH FQDN & URL $fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version); @@ -618,7 +618,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $domainValue = $port ? $urlWithPort : $url; if (is_null($domainExists)) { - $domains->put((string) $serviceName, [ + $domains->put($serviceName, [ 'domain' => $domainValue, ]); $resource->docker_compose_domains = $domains->toJson(); diff --git a/tests/Unit/ApplicationParserStringableTest.php b/tests/Unit/ApplicationParserStringableTest.php new file mode 100644 index 000000000..dfb1870db --- /dev/null +++ b/tests/Unit/ApplicationParserStringableTest.php @@ -0,0 +1,182 @@ +replace('_', '-')->value(); + $originalServiceName = str($serviceName)->replace('_', '-')->value(); + + // Line 541: $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + + // Verify both are plain strings, not Stringable objects + expect(is_string($originalServiceName))->toBeTrue('$originalServiceName should be a plain string'); + expect(is_string($serviceName))->toBeTrue('$serviceName should be a plain string'); + expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class); + expect($serviceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class); + + // Verify the transformations work correctly + expect($originalServiceName)->toBe('my-service'); + expect($serviceName)->toBe('my_service'); +}); + +it('ensures strict comparison works with normalized service names', function () { + // This tests the fix for line 606 where strict comparison failed + + // Simulate service name from docker-compose services array (line 604-605) + $serviceNameKey = 'my-service'; + $transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value(); + + // Simulate service name from environment variable parsing (line 520, 541) + $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-service'); + $serviceName = $parsed['service_name']; + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + + // Line 606: if ($transformedServiceName === $serviceName) + // This MUST work - both should be plain strings and match + expect($transformedServiceName === $serviceName)->toBeTrue( + 'Strict comparison should work when both are plain strings' + ); + expect($transformedServiceName)->toBe($serviceName); +}); + +it('ensures collection key lookup works with normalized service names', function () { + // This tests the fix for line 615 where collection->get() failed + + // Simulate service name normalization (line 541) + $parsed = parseServiceEnvironmentVariable('SERVICE_URL_app-name'); + $serviceName = $parsed['service_name']; + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + + // Create a collection like $domains at line 614 + $domains = collect([ + 'app_name' => [ + 'domain' => 'https://example.com', + ], + ]); + + // Line 615: $domainExists = data_get($domains->get($serviceName), 'domain'); + // This MUST work - $serviceName should be a plain string 'app_name' + $domainExists = data_get($domains->get($serviceName), 'domain'); + + expect($domainExists)->toBe('https://example.com', 'Collection lookup should find the domain'); + expect($domainExists)->not->toBeNull('Collection lookup should not return null'); +}); + +it('handles service names with dots correctly', function () { + // Test service names with dots (e.g., 'my.service') + + $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my.service'); + $serviceName = $parsed['service_name']; + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + + expect(is_string($serviceName))->toBeTrue(); + expect($serviceName)->toBe('my_service'); + + // Verify it matches transformed service name from docker-compose + $serviceNameKey = 'my.service'; + $transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value(); + + expect($transformedServiceName === $serviceName)->toBeTrue(); +}); + +it('handles service names with underscores correctly', function () { + // Test service names that already have underscores + + $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my_service'); + $serviceName = $parsed['service_name']; + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + + expect(is_string($serviceName))->toBeTrue(); + expect($serviceName)->toBe('my_service'); +}); + +it('handles mixed special characters in service names', function () { + // Test service names with mix of dashes, dots, underscores + + $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-app.service_v2'); + $serviceName = $parsed['service_name']; + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + + expect(is_string($serviceName))->toBeTrue(); + expect($serviceName)->toBe('my_app_service_v2'); + + // Verify collection operations work + $domains = collect([ + 'my_app_service_v2' => ['domain' => 'https://test.com'], + ]); + + $found = $domains->get($serviceName); + expect($found)->not->toBeNull(); + expect($found['domain'])->toBe('https://test.com'); +}); + +it('ensures originalServiceName conversion works for FQDN generation', function () { + // Test line 539: $originalServiceName conversion + + $parsed = parseServiceEnvironmentVariable('SERVICE_URL_my_service'); + $serviceName = $parsed['service_name']; // 'my_service' + + // Line 539: Convert underscores to dashes for FQDN generation + $originalServiceName = str($serviceName)->replace('_', '-')->value(); + + expect(is_string($originalServiceName))->toBeTrue(); + expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class); + expect($originalServiceName)->toBe('my-service'); + + // Verify it can be used in string interpolation (line 544) + $uuid = 'test-uuid'; + $random = "$originalServiceName-$uuid"; + expect($random)->toBe('my-service-test-uuid'); +}); + +it('prevents duplicate domain entries in collection', function () { + // This tests that using plain strings prevents duplicate entries + // (one with Stringable key, one with string key) + + $parsed = parseServiceEnvironmentVariable('SERVICE_URL_webapp'); + $serviceName = $parsed['service_name']; + $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + + $domains = collect(); + + // Add domain entry (line 621) + $domains->put($serviceName, [ + 'domain' => 'https://webapp.com', + ]); + + // Try to lookup the domain (line 615) + $found = $domains->get($serviceName); + + expect($found)->not->toBeNull('Should find the domain we just added'); + expect($found['domain'])->toBe('https://webapp.com'); + + // Verify only one entry exists + expect($domains->count())->toBe(1); + expect($domains->has($serviceName))->toBeTrue(); +}); + +it('verifies parsers.php has the ->value() calls', function () { + // Ensure the fix is actually in the code + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Line 539: Check originalServiceName conversion + expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()"); + + // Line 541: Check serviceName normalization + expect($parsersFile)->toContain("str(\$serviceName)->replace('-', '_')->replace('.', '_')->value()"); +});