fix(compose): normalize service-name keys for domains and env vars (#11040)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-08-03 23:08:56 +02:00
1 parent 7b18777f06
commit 0b843bb07c
18 files changed
+1443 -220

No files matched your search

+21 -16
View File
@@ -1343,19 +1343,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id === 0) {
// Generate SERVICE_ variables first for dockercompose
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
$domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
// Env keys always use underscore-normalized names so hyphen/dot storage keys stay valid.
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
$parsedDomain = composeDomainEntryString($domain);
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn);
}
}
@@ -1413,19 +1415,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
// Generate SERVICE_ variables first for dockercompose preview
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
$parsedDomain = composeDomainEntryString($domain);
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.$coolifyFqdn);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn);
}
}
@@ -1664,17 +1667,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
$domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
$parsedDomain = composeDomainEntryString($domain);
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs_dict['SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyFqdn);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn);
}
}
} else {
@@ -1686,17 +1690,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
$parsedDomain = composeDomainEntryString($domain);
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs_dict['SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyFqdn);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn);
}
}
}
+62 -25
View File
@@ -301,12 +301,7 @@ class General extends Component
}
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain;
}
$this->parsedServiceDomains = $sanitizedDomains;
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
$this->customLabels = $this->application->parseContainerLabels();
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) {
@@ -520,12 +515,7 @@ class General extends Component
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain;
}
$this->parsedServiceDomains = $sanitizedDomains;
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded');
@@ -551,22 +541,14 @@ class General extends Component
$uuid = new_public_id();
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedKey = normalizeComposeServiceName($serviceName);
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
// Convert back to original service names for storage
$originalDomains = [];
$composeServiceNames = collect(data_get($this->parsedServices, 'services', []))->keys()->map(fn ($name) => (string) $name)->values()->all();
foreach ($this->parsedServiceDomains as $key => $value) {
// Find the original service name by checking parsed services
$originalServiceName = $key;
if (isset($this->parsedServices['services'])) {
foreach ($this->parsedServices['services'] as $originalName => $service) {
if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) {
$originalServiceName = $originalName;
break;
}
}
}
$originalServiceName = findComposeServiceName((string) $key, $composeServiceNames) ?? (string) $key;
$originalDomains[$originalServiceName] = $value;
}
@@ -858,9 +840,10 @@ class General extends Component
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
$originalDomains = $this->composeDomainsForStorage();
$this->application->docker_compose_domains = json_encode($originalDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
foreach ($originalDomains as $service) {
$domain = data_get($service, 'domain');
if ($domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
@@ -985,4 +968,58 @@ class General extends Component
'{workdir}/.env'
);
}
private function composeDomainsForStorage(): array
{
return rekeyComposeDomainsToServiceNames(
$this->parsedServiceDomains,
collect(data_get($this->parsedServices, 'services', []))->keys(),
);
}
/**
* Collapse domain map keys to underscore form keys for Livewire/HTML binding.
* When twin keys exist, prefer a filled domain over a blank one.
*
* @param array<string, mixed> $domains
* @return array<string, mixed>
*/
private function sanitizeParsedServiceDomainsForForm(array $domains): array
{
$sanitizedDomains = [];
foreach ($domains as $serviceName => $domain) {
$sanitizedKey = normalizeComposeServiceName((string) $serviceName);
if (! array_key_exists($sanitizedKey, $sanitizedDomains)) {
$sanitizedDomains[$sanitizedKey] = $domain;
continue;
}
$existing = $sanitizedDomains[$sanitizedKey];
if (is_object($existing)) {
$existing = (array) $existing;
}
if (is_object($domain)) {
$domain = (array) $domain;
}
if (! is_array($existing)) {
$existing = ['domain' => $existing];
}
if (! is_array($domain)) {
$domain = ['domain' => $domain];
}
$merged = array_merge($existing, $domain);
$merged['domain'] = preferComposeDomainValue(
$existing['domain'] ?? null,
false,
$domain['domain'] ?? null,
false,
);
$sanitizedDomains[$sanitizedKey] = $merged;
}
return $sanitizedDomains;
}
}
@@ -39,13 +39,7 @@ class PreviewsCompose extends Component
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
$docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? [];
$docker_compose_domains[$this->serviceName]['domain'] = $this->domain;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
$this->persistPreviewDomain($this->domain);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain saved.');
} catch (\Throwable $e) {
@@ -58,12 +52,8 @@ class PreviewsCompose extends Component
try {
$this->authorize('update', $this->preview->application);
$domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []);
$domain = $domains->first(function ($_, $key) {
return $key === $this->serviceName;
});
$domain_string = data_get($domain, 'domain');
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
$domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName);
// If no domain is set in the main application, generate a default domain
if (empty($domain_string)) {
@@ -111,14 +101,8 @@ class PreviewsCompose extends Component
$preview_fqdn = implode(',', $preview_fqdns);
}
// Save the generated domain
$this->domain = $preview_fqdn;
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
$docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? [];
$docker_compose_domains[$this->serviceName]['domain'] = $this->domain;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
$this->persistPreviewDomain($this->domain);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
@@ -126,4 +110,56 @@ class PreviewsCompose extends Component
return handleError($e, $this);
}
}
private function persistPreviewDomain(?string $domain): void
{
$docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: [];
$serviceNames = $this->previewServiceNames($docker_compose_domains);
$storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames)
?? (string) $this->serviceName;
$docker_compose_domains = putComposeServiceDomain(
$docker_compose_domains,
$storageKey,
$domain,
$serviceNames,
);
$docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames);
$this->serviceName = $storageKey;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
}
/**
* @param array<string, mixed> $previewDomains
* @return list<string>
*/
private function previewServiceNames(array $previewDomains): array
{
$parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
$fromCompose = collect(data_get($parsedServices, 'services', []))
->keys()
->map(function ($serviceName) {
return str((string) $serviceName)
->replaceLast('-pr-'.$this->preview->pull_request_id, '')
->toString();
})
->all();
$domainKeys = collect(array_keys($previewDomains))
->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []))
->map(fn ($name) => (string) $name);
$unmapped = $domainKeys
->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null)
->all();
return collect($fromCompose)
->merge(preferredComposeServiceNamesFromDomainKeys(
$fromCompose === [] ? $domainKeys->all() : $unmapped
))
->unique()
->values()
->all();
}
}
+26 -28
View File
@@ -2082,34 +2082,7 @@ class Application extends BaseModel
$this->save();
$parsedServices = $this->parse();
if ($this->docker_compose_domains) {
$decoded = json_decode($this->docker_compose_domains, true);
$json = collect(is_array($decoded) ? $decoded : []);
$normalized = collect();
foreach ($json as $key => $value) {
$normalizedKey = (string) str($key)->replace('-', '_')->replace('.', '_');
$normalized->put($normalizedKey, $value);
}
$json = $normalized;
$services = collect(data_get($parsedServices, 'services', []));
foreach ($services as $name => $service) {
if (str($name)->contains('-') || str($name)->contains('.')) {
$replacedName = str($name)->replace('-', '_')->replace('.', '_');
$services->put((string) $replacedName, $service);
$services->forget((string) $name);
}
}
$names = collect($services)->keys()->toArray();
$jsonNames = $json->keys()->toArray();
$diff = array_diff($jsonNames, $names);
$json = $json->filter(function ($value, $key) use ($diff) {
return ! in_array($key, $diff);
});
if ($json) {
$this->docker_compose_domains = json_encode($json);
} else {
$this->docker_compose_domains = null;
}
$this->save();
$this->reconcileDockerComposeDomains($parsedServices);
}
return [
@@ -2126,6 +2099,31 @@ class Application extends BaseModel
}
}
private function reconcileDockerComposeDomains(mixed $parsedServices): void
{
$services = collect(data_get($parsedServices, 'services', []));
$serviceNames = $services->keys()->map(fn ($name) => (string) $name)->all();
if ($serviceNames === []) {
return;
}
$decoded = json_decode($this->docker_compose_domains, true);
$rekeyed = rekeyComposeDomainsToServiceNames(
is_array($decoded) ? $decoded : [],
$serviceNames,
);
$domains = collect($rekeyed)->filter(
fn ($value, $key) => findComposeServiceName((string) $key, $serviceNames) !== null
);
$this->docker_compose_domains = $domains->isNotEmpty()
? json_encode($domains->all())
: null;
$this->save();
}
public function parseContainerLabels(?ApplicationPreview $preview = null)
{
$customLabels = data_get($this, 'custom_labels');
+77 -26
View File
@@ -124,34 +124,55 @@ class ApplicationPreview extends BaseModel
public function generate_preview_fqdn_compose()
{
$services = collect(json_decode($this->application->docker_compose_domains)) ?? collect();
$docker_compose_domains = data_get($this, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?? [];
$applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: [];
$previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: [];
// Get all services from the parsed compose file to ensure all services have entries
$parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id);
if (isset($parsedServices['services'])) {
foreach ($parsedServices['services'] as $serviceName => $service) {
if (! isDatabaseImage(data_get($service, 'image'))) {
// Remove PR suffix from service name to get original service name
$originalServiceName = str($serviceName)->replaceLast('-pr-'.$this->pull_request_id, '')->toString();
$composeServiceNames = $this->composeServiceNamesForPreview();
// Canonical compose names first; collapse leftover domain-map twins when parse is empty/missing.
$domainKeys = collect(array_keys($applicationDomains))
->merge(array_keys($previewDomains))
->map(fn ($name) => (string) $name);
$unmappedDomainKeys = $domainKeys
->reject(fn (string $key) => findComposeServiceName($key, $composeServiceNames) !== null)
->all();
$knownServiceNames = collect($composeServiceNames)
->merge(preferredComposeServiceNamesFromDomainKeys(
$composeServiceNames === [] ? $domainKeys->all() : $unmappedDomainKeys
))
->unique()
->values()
->all();
// Ensure all services have an entry, even if empty
if (! $services->has($originalServiceName)) {
$services->put($originalServiceName, ['domain' => '']);
}
}
$applicationDomains = rekeyComposeDomainsToServiceNames($applicationDomains, $knownServiceNames);
$previewDomains = rekeyComposeDomainsToServiceNames($previewDomains, $knownServiceNames);
// Ensure every non-database compose service has a domain slot (empty if unset).
foreach ($composeServiceNames as $serviceName) {
if (! array_key_exists($serviceName, $applicationDomains)) {
$applicationDomains[$serviceName] = ['domain' => ''];
}
}
foreach ($services as $service_name => $service_config) {
$domain_string = data_get($service_config, 'domain');
$serviceNames = collect(array_keys($applicationDomains))
->merge($composeServiceNames)
->map(fn ($name) => (string) $name)
->unique()
->values()
->all();
$docker_compose_domains = [];
foreach ($serviceNames as $service_name) {
$domain_string = getComposeServiceDomainString($applicationDomains, $service_name);
// If domain string is empty or null, don't auto-generate domain
// Only generate domains when main app already has domains set
if (empty($domain_string)) {
// Ensure service has an empty domain entry for form binding
$docker_compose_domains[$service_name]['domain'] = '';
$docker_compose_domains = putComposeServiceDomain(
$docker_compose_domains,
$service_name,
'',
$serviceNames,
);
continue;
}
@@ -180,19 +201,22 @@ class ApplicationPreview extends BaseModel
$preview_domains[] = $preview_fqdn;
}
if (! empty($preview_domains)) {
$docker_compose_domains[$service_name]['domain'] = implode(',', $preview_domains);
} else {
// Ensure service has an empty domain entry for form binding
$docker_compose_domains[$service_name]['domain'] = '';
}
$docker_compose_domains = putComposeServiceDomain(
$docker_compose_domains,
$service_name,
! empty($preview_domains) ? implode(',', $preview_domains) : '',
$serviceNames,
);
}
// Drop any leftover twin keys that were not rewritten above.
$docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames);
$this->docker_compose_domains = json_encode($docker_compose_domains);
// Populate fqdn from generated domains so webhook notifications can read it
$allDomains = collect($docker_compose_domains)
->pluck('domain')
->map(fn ($entry) => composeDomainEntryString($entry))
->filter(fn ($d) => ! empty($d))
->flatMap(fn ($d) => explode(',', $d))
->implode(',');
@@ -201,4 +225,31 @@ class ApplicationPreview extends BaseModel
$this->save();
}
/**
* Original compose service names for this preview (PR suffix stripped), excluding database images.
*
* @return list<string>
*/
private function composeServiceNamesForPreview(): array
{
$parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id);
$services = data_get($parsedServices, 'services', []);
if (! is_iterable($services)) {
return [];
}
$names = [];
foreach ($services as $serviceName => $service) {
if (isDatabaseImage(data_get($service, 'image'))) {
continue;
}
$names[] = str((string) $serviceName)
->replaceLast('-pr-'.$this->pull_request_id, '')
->toString();
}
return array_values(array_unique($names));
}
}