From 7f46b2ed71230413385c7ec9dc59b0ea3f4cd19d Mon Sep 17 00:00:00 2001 From: Tobias Thiele Date: Mon, 30 Mar 2026 14:17:49 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Populate=20docker=5Fcompose=5Fd?= =?UTF-8?q?omains=20for=20dockercompose=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add logic to populate docker_compose_domains in applicationParser for dockercompose build packs, ensuring proper domain handling for KEY-based SERVICE_FQDN variables. --- .../Api/ApplicationsController.php | 6 +- bootstrap/helpers/parsers.php | 22 +++ ...licationParserDockerComposeDomainsTest.php | 144 ++++++++++++++++++ 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ApplicationParserDockerComposeDomainsTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 77f4e626f..f72d77ae6 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1229,7 +1229,7 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $repository_url_parsed = Url::fromString($request->git_repository); $git_host = $repository_url_parsed->getHost(); @@ -1461,7 +1461,7 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1665,7 +1665,7 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 751851283..aac075746 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -504,6 +504,28 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); } + + // Also populate docker_compose_domains for dockercompose apps (mirrors Path 1 at lines 601-627) + if ($resource->build_pack === 'dockercompose') { + $normalizedFqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_')->value(); + $serviceExists = false; + foreach ($services as $serviceNameKey => $svc) { + if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedFqdnFor) { + $serviceExists = true; + break; + } + } + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domainExists = data_get($domains->get($normalizedFqdnFor), 'domain'); + if (is_null($domainExists)) { + $domainValue = $fqdn; + $domains->put($normalizedFqdnFor, ['domain' => $domainValue]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); + } + } + } } } diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php new file mode 100644 index 000000000..3d8ab15fb --- /dev/null +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -0,0 +1,144 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $ecKey = EC::createKey('Ed25519'); + $privateKeyContent = $ecKey->toString('OpenSSH'); + + $privateKey = PrivateKey::create([ + 'name' => 'test-key', + 'private_key' => $privateKeyContent, + 'team_id' => $this->team->id, + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + + ServerSetting::create([ + 'server_id' => $this->server->id, + 'wildcard_domain' => 'http://127.0.0.1.sslip.io', + ]); + + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'test-network-'.fake()->uuid(), + ]); +}); + +test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN variables', function () { + $dockerCompose = <<<'YAML' +services: + backend: + image: myapp/backend:latest + environment: + - SERVICE_FQDN_BACKEND_8000=${BACKEND_URL} + frontend: + image: myapp/frontend:latest + environment: + - SERVICE_FQDN_FRONTEND=${FRONTEND_URL} +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + + applicationParser($application); + + $application->refresh(); + + $domains = json_decode($application->docker_compose_domains, true); + + expect($domains)->not->toBeNull() + ->and($domains)->toBeArray() + ->and($domains)->toHaveKey('backend') + ->and($domains['backend'])->toHaveKey('domain') + ->and($domains['backend']['domain'])->not->toBeEmpty(); +}); + +test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN without port', function () { + $dockerCompose = <<<'YAML' +services: + frontend: + image: myapp/frontend:latest + environment: + - SERVICE_FQDN_FRONTEND=${FRONTEND_URL} +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + + applicationParser($application); + + $application->refresh(); + + $domains = json_decode($application->docker_compose_domains, true); + + expect($domains)->not->toBeNull() + ->and($domains)->toHaveKey('frontend') + ->and($domains['frontend'])->toHaveKey('domain'); +}); + +test('applicationParser does not populate docker_compose_domains for non-dockercompose build_pack', function () { + $dockerCompose = <<<'YAML' +services: + backend: + image: myapp/backend:latest + environment: + - SERVICE_FQDN_BACKEND_8000=${BACKEND_URL} +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockerfile', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + + applicationParser($application); + + $application->refresh(); + + // For non-dockercompose, docker_compose_domains should remain empty + $domains = json_decode($application->docker_compose_domains, true); + expect($domains)->toBeNull(); +}); From 8b12aae2665b484b328ab2c46c463600c78eaf32 Mon Sep 17 00:00:00 2001 From: Tobias Thiele Date: Thu, 2 Apr 2026 09:23:47 +0200 Subject: [PATCH 2/3] fix(parsers): remove unused $svc variable in dockercompose domain loop Use array_keys() instead of key-value iteration since only the service name keys are needed for comparison. Addresses CodeRabbit review feedback. --- bootstrap/helpers/parsers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index aac075746..d1cad5bb6 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -509,7 +509,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($resource->build_pack === 'dockercompose') { $normalizedFqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_')->value(); $serviceExists = false; - foreach ($services as $serviceNameKey => $svc) { + foreach (array_keys($services) as $serviceNameKey) { if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedFqdnFor) { $serviceExists = true; break; From eddcbe819b0b962e3c2d07d8c080c3dbfe357434 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:57:02 +0200 Subject: [PATCH 3/3] fix(parser): populate compose domains from service env keys --- bootstrap/helpers/parsers.php | 48 +++--- ...licationParserDockerComposeDomainsTest.php | 145 ++++++++++++++++++ 2 files changed, 175 insertions(+), 18 deletions(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e7acc3c61..62301d2aa 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -502,25 +502,37 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ]); } - // Also populate docker_compose_domains for dockercompose apps (mirrors Path 1 at lines 601-627) - if ($resource->build_pack === 'dockercompose') { - $normalizedFqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_')->value(); - $serviceExists = false; - foreach (array_keys($services) as $serviceNameKey) { - if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedFqdnFor) { - $serviceExists = true; - break; - } + } + + // Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations. + if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) { + $parsed = parseServiceEnvironmentVariable($key->value()); + $normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value(); + $serviceExists = false; + foreach (array_keys($services) as $serviceNameKey) { + if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) { + $serviceExists = true; + break; } - if ($serviceExists) { - $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); - $domainExists = data_get($domains->get($normalizedFqdnFor), 'domain'); - if (is_null($domainExists)) { - $domainValue = $fqdn; - $domains->put($normalizedFqdnFor, ['domain' => $domainValue]); - $resource->docker_compose_domains = $domains->toJson(); - $resource->save(); + } + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]')); + $domainExists = data_get($domains->get($normalizedServiceName), 'domain'); + if (is_null($domainExists)) { + $serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value(); + $domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid"); + if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) { + $path = $value->value(); + if ($path !== '/') { + $domainValue = "$domainValue$path"; + } } + if ($parsed['port'] && is_numeric($parsed['port'])) { + $domainValue = "$domainValue:{$parsed['port']}"; + } + $domains->put($normalizedServiceName, ['domain' => $domainValue]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); } } } @@ -630,7 +642,7 @@ 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([]); + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]')); $domainExists = data_get($domains->get($serviceName), 'domain'); // Update domain using URL with port if applicable diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 3d8ab15fb..00ae2d2ff 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -82,6 +82,7 @@ ->and($domains)->toBeArray() ->and($domains)->toHaveKey('backend') ->and($domains['backend'])->toHaveKey('domain') + ->and($domains['backend']['domain'])->toStartWith('http://') ->and($domains['backend']['domain'])->not->toBeEmpty(); }); @@ -142,3 +143,147 @@ $domains = json_decode($application->docker_compose_domains, true); expect($domains)->toBeNull(); }); + +test('applicationParser populates docker_compose_domains for KEY-based SERVICE_URL variables', function () { + $dockerCompose = <<<'YAML' +services: + frontend: + image: myapp/frontend:latest + environment: + - SERVICE_URL_FRONTEND=/ui +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + + applicationParser($application); + + $application->refresh(); + + $domains = json_decode($application->docker_compose_domains, true); + + expect($domains)->not->toBeNull() + ->and($domains)->toHaveKey('frontend') + ->and($domains['frontend']['domain'])->toStartWith('http://') + ->and($domains['frontend']['domain'])->toEndWith('/ui'); +}); + +test('applicationParser preserves existing docker_compose_domains entries', function () { + $dockerCompose = <<<'YAML' +services: + backend: + image: myapp/backend:latest + environment: + - SERVICE_FQDN_BACKEND_8000=${BACKEND_URL} + frontend: + image: myapp/frontend:latest + environment: + - SERVICE_URL_FRONTEND=/ui +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://existing.example.com'], + ]), + ]); + + applicationParser($application); + + $application->refresh(); + + $domains = json_decode($application->docker_compose_domains, true); + + expect($domains['frontend']['domain'])->toBe('https://existing.example.com') + ->and($domains)->toHaveKey('backend') + ->and($domains['backend']['domain'])->toStartWith('http://'); +}); + +test('applicationParser handles other docker compose domain shapes without regressions', function () { + $createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application { + return Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => $dockerComposeDomains, + ]); + }; + + $valueBasedCompose = <<<'YAML' +services: + backend: + image: myapp/backend:latest + frontend: + image: myapp/frontend:latest + environment: + API_URL: ${SERVICE_URL_BACKEND} +YAML; + + $valueBasedApplication = $createApplication($valueBasedCompose); + applicationParser($valueBasedApplication); + $valueBasedApplication->refresh(); + $valueBasedDomains = json_decode($valueBasedApplication->docker_compose_domains, true); + + expect($valueBasedDomains)->toHaveKey('backend') + ->and($valueBasedDomains['backend']['domain'])->toStartWith('http://') + ->and($valueBasedDomains)->not->toHaveKey('frontend'); + + $mapStyleCompose = <<<'YAML' +services: + frontend: + image: myapp/frontend:latest + environment: + SERVICE_URL_FRONTEND: /ui +YAML; + + $mapStyleApplication = $createApplication($mapStyleCompose); + applicationParser($mapStyleApplication); + $mapStyleApplication->refresh(); + $mapStyleDomains = json_decode($mapStyleApplication->docker_compose_domains, true); + + expect($mapStyleDomains)->toHaveKey('frontend') + ->and($mapStyleDomains['frontend']['domain'])->toEndWith('/ui'); + + $missingServiceCompose = <<<'YAML' +services: + worker: + image: myapp/worker:latest + environment: + SERVICE_URL_API: /api +YAML; + + $missingServiceApplication = $createApplication($missingServiceCompose); + applicationParser($missingServiceApplication); + $missingServiceApplication->refresh(); + + expect(json_decode($missingServiceApplication->docker_compose_domains, true))->toBeNull(); + + $plainCompose = <<<'YAML' +services: + worker: + image: myapp/worker:latest + environment: + FOO: bar +YAML; + + $plainApplication = $createApplication($plainCompose); + applicationParser($plainApplication); + $plainApplication->refresh(); + + expect(json_decode($plainApplication->docker_compose_domains, true))->toBeNull(); +});