Skip to content

Commit d01e3a9

Browse files
authored
fix(parsers): populate docker_compose_domains for API-created Docker Compose apps (#9300)
2 parents d657c10 + eddcbe8 commit d01e3a9

3 files changed

Lines changed: 327 additions & 4 deletions

File tree

app/Http/Controllers/Api/ApplicationsController.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,7 +1141,7 @@ private function create_application(Request $request, $type)
11411141
$request->offsetUnset('docker_compose_domains');
11421142
}
11431143
if ($dockerComposeDomainsJson->count() > 0) {
1144-
$application->docker_compose_domains = $dockerComposeDomainsJson;
1144+
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
11451145
}
11461146
$repository_url_parsed = Url::fromString($request->git_repository);
11471147
$git_host = $repository_url_parsed->getHost();
@@ -1382,7 +1382,7 @@ private function create_application(Request $request, $type)
13821382
$request->offsetUnset('docker_compose_domains');
13831383
}
13841384
if ($dockerComposeDomainsJson->count() > 0) {
1385-
$application->docker_compose_domains = $dockerComposeDomainsJson;
1385+
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
13861386
}
13871387
$application->fqdn = $fqdn;
13881388
$application->git_repository = str($gitRepository)->trim()->toString();
@@ -1595,7 +1595,7 @@ private function create_application(Request $request, $type)
15951595
$request->offsetUnset('docker_compose_domains');
15961596
}
15971597
if ($dockerComposeDomainsJson->count() > 0) {
1598-
$application->docker_compose_domains = $dockerComposeDomainsJson;
1598+
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
15991599
}
16001600
$application->fqdn = $fqdn;
16011601
$application->private_key_id = $privateKey->id;

bootstrap/helpers/parsers.php

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,40 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
501501
'is_preview' => false,
502502
]);
503503
}
504+
505+
}
506+
507+
// Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations.
508+
if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
509+
$parsed = parseServiceEnvironmentVariable($key->value());
510+
$normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value();
511+
$serviceExists = false;
512+
foreach (array_keys($services) as $serviceNameKey) {
513+
if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) {
514+
$serviceExists = true;
515+
break;
516+
}
517+
}
518+
if ($serviceExists) {
519+
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
520+
$domainExists = data_get($domains->get($normalizedServiceName), 'domain');
521+
if (is_null($domainExists)) {
522+
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
523+
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
524+
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
525+
$path = $value->value();
526+
if ($path !== '/') {
527+
$domainValue = "$domainValue$path";
528+
}
529+
}
530+
if ($parsed['port'] && is_numeric($parsed['port'])) {
531+
$domainValue = "$domainValue:{$parsed['port']}";
532+
}
533+
$domains->put($normalizedServiceName, ['domain' => $domainValue]);
534+
$resource->docker_compose_domains = $domains->toJson();
535+
$resource->save();
536+
}
537+
}
504538
}
505539
}
506540

@@ -608,7 +642,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
608642

609643
// Only add domain if the service exists
610644
if ($serviceExists) {
611-
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
645+
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
612646
$domainExists = data_get($domains->get($serviceName), 'domain');
613647

614648
// Update domain using URL with port if applicable
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
<?php
2+
3+
use App\Models\Application;
4+
use App\Models\Environment;
5+
use App\Models\PrivateKey;
6+
use App\Models\Project;
7+
use App\Models\Server;
8+
use App\Models\ServerSetting;
9+
use App\Models\StandaloneDocker;
10+
use App\Models\Team;
11+
use App\Models\User;
12+
use Illuminate\Foundation\Testing\RefreshDatabase;
13+
use phpseclib3\Crypt\EC;
14+
15+
uses(RefreshDatabase::class);
16+
17+
beforeEach(function () {
18+
$this->user = User::factory()->create();
19+
$this->team = Team::factory()->create();
20+
$this->user->teams()->attach($this->team);
21+
22+
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
23+
$this->environment = Environment::factory()->create([
24+
'project_id' => $this->project->id,
25+
]);
26+
27+
$ecKey = EC::createKey('Ed25519');
28+
$privateKeyContent = $ecKey->toString('OpenSSH');
29+
30+
$privateKey = PrivateKey::create([
31+
'name' => 'test-key',
32+
'private_key' => $privateKeyContent,
33+
'team_id' => $this->team->id,
34+
]);
35+
36+
$this->server = Server::factory()->create([
37+
'team_id' => $this->team->id,
38+
'private_key_id' => $privateKey->id,
39+
]);
40+
41+
ServerSetting::create([
42+
'server_id' => $this->server->id,
43+
'wildcard_domain' => 'http://127.0.0.1.sslip.io',
44+
]);
45+
46+
$this->destination = StandaloneDocker::factory()->create([
47+
'server_id' => $this->server->id,
48+
'network' => 'test-network-'.fake()->uuid(),
49+
]);
50+
});
51+
52+
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN variables', function () {
53+
$dockerCompose = <<<'YAML'
54+
services:
55+
backend:
56+
image: myapp/backend:latest
57+
environment:
58+
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
59+
frontend:
60+
image: myapp/frontend:latest
61+
environment:
62+
- SERVICE_FQDN_FRONTEND=${FRONTEND_URL}
63+
YAML;
64+
65+
$application = Application::factory()->create([
66+
'environment_id' => $this->environment->id,
67+
'destination_id' => $this->destination->id,
68+
'destination_type' => StandaloneDocker::class,
69+
'build_pack' => 'dockercompose',
70+
'docker_compose_raw' => $dockerCompose,
71+
'fqdn' => null,
72+
'docker_compose_domains' => null,
73+
]);
74+
75+
applicationParser($application);
76+
77+
$application->refresh();
78+
79+
$domains = json_decode($application->docker_compose_domains, true);
80+
81+
expect($domains)->not->toBeNull()
82+
->and($domains)->toBeArray()
83+
->and($domains)->toHaveKey('backend')
84+
->and($domains['backend'])->toHaveKey('domain')
85+
->and($domains['backend']['domain'])->toStartWith('http://')
86+
->and($domains['backend']['domain'])->not->toBeEmpty();
87+
});
88+
89+
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN without port', function () {
90+
$dockerCompose = <<<'YAML'
91+
services:
92+
frontend:
93+
image: myapp/frontend:latest
94+
environment:
95+
- SERVICE_FQDN_FRONTEND=${FRONTEND_URL}
96+
YAML;
97+
98+
$application = Application::factory()->create([
99+
'environment_id' => $this->environment->id,
100+
'destination_id' => $this->destination->id,
101+
'destination_type' => StandaloneDocker::class,
102+
'build_pack' => 'dockercompose',
103+
'docker_compose_raw' => $dockerCompose,
104+
'fqdn' => null,
105+
'docker_compose_domains' => null,
106+
]);
107+
108+
applicationParser($application);
109+
110+
$application->refresh();
111+
112+
$domains = json_decode($application->docker_compose_domains, true);
113+
114+
expect($domains)->not->toBeNull()
115+
->and($domains)->toHaveKey('frontend')
116+
->and($domains['frontend'])->toHaveKey('domain');
117+
});
118+
119+
test('applicationParser does not populate docker_compose_domains for non-dockercompose build_pack', function () {
120+
$dockerCompose = <<<'YAML'
121+
services:
122+
backend:
123+
image: myapp/backend:latest
124+
environment:
125+
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
126+
YAML;
127+
128+
$application = Application::factory()->create([
129+
'environment_id' => $this->environment->id,
130+
'destination_id' => $this->destination->id,
131+
'destination_type' => StandaloneDocker::class,
132+
'build_pack' => 'dockerfile',
133+
'docker_compose_raw' => $dockerCompose,
134+
'fqdn' => null,
135+
'docker_compose_domains' => null,
136+
]);
137+
138+
applicationParser($application);
139+
140+
$application->refresh();
141+
142+
// For non-dockercompose, docker_compose_domains should remain empty
143+
$domains = json_decode($application->docker_compose_domains, true);
144+
expect($domains)->toBeNull();
145+
});
146+
147+
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_URL variables', function () {
148+
$dockerCompose = <<<'YAML'
149+
services:
150+
frontend:
151+
image: myapp/frontend:latest
152+
environment:
153+
- SERVICE_URL_FRONTEND=/ui
154+
YAML;
155+
156+
$application = Application::factory()->create([
157+
'environment_id' => $this->environment->id,
158+
'destination_id' => $this->destination->id,
159+
'destination_type' => StandaloneDocker::class,
160+
'build_pack' => 'dockercompose',
161+
'docker_compose_raw' => $dockerCompose,
162+
'fqdn' => null,
163+
'docker_compose_domains' => null,
164+
]);
165+
166+
applicationParser($application);
167+
168+
$application->refresh();
169+
170+
$domains = json_decode($application->docker_compose_domains, true);
171+
172+
expect($domains)->not->toBeNull()
173+
->and($domains)->toHaveKey('frontend')
174+
->and($domains['frontend']['domain'])->toStartWith('http://')
175+
->and($domains['frontend']['domain'])->toEndWith('/ui');
176+
});
177+
178+
test('applicationParser preserves existing docker_compose_domains entries', function () {
179+
$dockerCompose = <<<'YAML'
180+
services:
181+
backend:
182+
image: myapp/backend:latest
183+
environment:
184+
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
185+
frontend:
186+
image: myapp/frontend:latest
187+
environment:
188+
- SERVICE_URL_FRONTEND=/ui
189+
YAML;
190+
191+
$application = Application::factory()->create([
192+
'environment_id' => $this->environment->id,
193+
'destination_id' => $this->destination->id,
194+
'destination_type' => StandaloneDocker::class,
195+
'build_pack' => 'dockercompose',
196+
'docker_compose_raw' => $dockerCompose,
197+
'fqdn' => null,
198+
'docker_compose_domains' => json_encode([
199+
'frontend' => ['domain' => 'https://existing.example.com'],
200+
]),
201+
]);
202+
203+
applicationParser($application);
204+
205+
$application->refresh();
206+
207+
$domains = json_decode($application->docker_compose_domains, true);
208+
209+
expect($domains['frontend']['domain'])->toBe('https://existing.example.com')
210+
->and($domains)->toHaveKey('backend')
211+
->and($domains['backend']['domain'])->toStartWith('http://');
212+
});
213+
214+
test('applicationParser handles other docker compose domain shapes without regressions', function () {
215+
$createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
216+
return Application::factory()->create([
217+
'environment_id' => $this->environment->id,
218+
'destination_id' => $this->destination->id,
219+
'destination_type' => StandaloneDocker::class,
220+
'build_pack' => 'dockercompose',
221+
'docker_compose_raw' => $dockerCompose,
222+
'fqdn' => null,
223+
'docker_compose_domains' => $dockerComposeDomains,
224+
]);
225+
};
226+
227+
$valueBasedCompose = <<<'YAML'
228+
services:
229+
backend:
230+
image: myapp/backend:latest
231+
frontend:
232+
image: myapp/frontend:latest
233+
environment:
234+
API_URL: ${SERVICE_URL_BACKEND}
235+
YAML;
236+
237+
$valueBasedApplication = $createApplication($valueBasedCompose);
238+
applicationParser($valueBasedApplication);
239+
$valueBasedApplication->refresh();
240+
$valueBasedDomains = json_decode($valueBasedApplication->docker_compose_domains, true);
241+
242+
expect($valueBasedDomains)->toHaveKey('backend')
243+
->and($valueBasedDomains['backend']['domain'])->toStartWith('http://')
244+
->and($valueBasedDomains)->not->toHaveKey('frontend');
245+
246+
$mapStyleCompose = <<<'YAML'
247+
services:
248+
frontend:
249+
image: myapp/frontend:latest
250+
environment:
251+
SERVICE_URL_FRONTEND: /ui
252+
YAML;
253+
254+
$mapStyleApplication = $createApplication($mapStyleCompose);
255+
applicationParser($mapStyleApplication);
256+
$mapStyleApplication->refresh();
257+
$mapStyleDomains = json_decode($mapStyleApplication->docker_compose_domains, true);
258+
259+
expect($mapStyleDomains)->toHaveKey('frontend')
260+
->and($mapStyleDomains['frontend']['domain'])->toEndWith('/ui');
261+
262+
$missingServiceCompose = <<<'YAML'
263+
services:
264+
worker:
265+
image: myapp/worker:latest
266+
environment:
267+
SERVICE_URL_API: /api
268+
YAML;
269+
270+
$missingServiceApplication = $createApplication($missingServiceCompose);
271+
applicationParser($missingServiceApplication);
272+
$missingServiceApplication->refresh();
273+
274+
expect(json_decode($missingServiceApplication->docker_compose_domains, true))->toBeNull();
275+
276+
$plainCompose = <<<'YAML'
277+
services:
278+
worker:
279+
image: myapp/worker:latest
280+
environment:
281+
FOO: bar
282+
YAML;
283+
284+
$plainApplication = $createApplication($plainCompose);
285+
applicationParser($plainApplication);
286+
$plainApplication->refresh();
287+
288+
expect(json_decode($plainApplication->docker_compose_domains, true))->toBeNull();
289+
});

0 commit comments

Comments
 (0)