fix: comprehensive SERVICE_URL/SERVICE_FQDN handling improvements and queue reliability fixes (#7275)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2025-11-24 11:47:11 +01:00
commit e0dc12678b
9 files changed
+1518 -375

No files matched your search

@@ -0,0 +1,182 @@
<?php
/**
* Unit tests to verify that the applicationParser function in parsers.php
* properly converts Stringable objects to plain strings to fix strict
* comparison and collection key lookup issues.
*
* Related issue: Lines 539 and 541 in parsers.php were creating Stringable
* objects which caused:
* - Strict comparisons (===) to fail (line 606)
* - Collection key lookups to fail (line 615)
*/
it('ensures service name normalization returns plain strings not Stringable objects', function () {
// Test the exact transformations that happen in parsers.php lines 539-541
// Simulate what happens at line 520
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-service');
$serviceName = $parsed['service_name']; // 'my-service'
// Line 539: $originalServiceName = str($serviceName)->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()");
});
@@ -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}');
});
+117
View File
@@ -151,3 +151,120 @@ it('checks if all FQDNs have port - null FQDN', function () {
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]);
});
@@ -0,0 +1,563 @@
<?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
});
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');
});