fix(compose): normalize service-name keys for domains and env vars (#11040)
This commit is contained in:
parent
7b18777f06
commit
0b843bb07c
18 changed files with 1443 additions and 220 deletions
|
|
@ -1343,19 +1343,21 @@ private function generate_runtime_environment_variables()
|
||||||
if ($this->pull_request_id === 0) {
|
if ($this->pull_request_id === 0) {
|
||||||
// Generate SERVICE_ variables first for dockercompose
|
// Generate SERVICE_ variables first for dockercompose
|
||||||
if ($this->build_pack === '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
|
// 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) {
|
foreach ($domains as $forServiceName => $domain) {
|
||||||
$parsedDomain = data_get($domain, 'domain');
|
$parsedDomain = composeDomainEntryString($domain);
|
||||||
if (filled($parsedDomain)) {
|
if (filled($parsedDomain)) {
|
||||||
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
||||||
$coolifyUrl = Url::fromString($parsedDomain);
|
$coolifyUrl = Url::fromString($parsedDomain);
|
||||||
$coolifyScheme = $coolifyUrl->getScheme();
|
$coolifyScheme = $coolifyUrl->getScheme();
|
||||||
$coolifyFqdn = $coolifyUrl->getHost();
|
$coolifyFqdn = $coolifyUrl->getHost();
|
||||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
||||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
|
$envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString());
|
||||||
|
$envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1413,19 +1415,20 @@ private function generate_runtime_environment_variables()
|
||||||
} else {
|
} else {
|
||||||
// Generate SERVICE_ variables first for dockercompose preview
|
// Generate SERVICE_ variables first for dockercompose preview
|
||||||
if ($this->build_pack === 'dockercompose') {
|
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
|
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
|
||||||
foreach ($domains as $forServiceName => $domain) {
|
foreach ($domains as $forServiceName => $domain) {
|
||||||
$parsedDomain = data_get($domain, 'domain');
|
$parsedDomain = composeDomainEntryString($domain);
|
||||||
if (filled($parsedDomain)) {
|
if (filled($parsedDomain)) {
|
||||||
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
||||||
$coolifyUrl = Url::fromString($parsedDomain);
|
$coolifyUrl = Url::fromString($parsedDomain);
|
||||||
$coolifyScheme = $coolifyUrl->getScheme();
|
$coolifyScheme = $coolifyUrl->getScheme();
|
||||||
$coolifyFqdn = $coolifyUrl->getHost();
|
$coolifyFqdn = $coolifyUrl->getHost();
|
||||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||||
$envs->push('SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.$coolifyUrl->__toString());
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
||||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.$coolifyFqdn);
|
$envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString());
|
||||||
|
$envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1664,17 +1667,18 @@ private function generate_buildtime_environment_variables()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
|
// 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) {
|
foreach ($domains as $forServiceName => $domain) {
|
||||||
$parsedDomain = data_get($domain, 'domain');
|
$parsedDomain = composeDomainEntryString($domain);
|
||||||
if (filled($parsedDomain)) {
|
if (filled($parsedDomain)) {
|
||||||
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
||||||
$coolifyUrl = Url::fromString($parsedDomain);
|
$coolifyUrl = Url::fromString($parsedDomain);
|
||||||
$coolifyScheme = $coolifyUrl->getScheme();
|
$coolifyScheme = $coolifyUrl->getScheme();
|
||||||
$coolifyFqdn = $coolifyUrl->getHost();
|
$coolifyFqdn = $coolifyUrl->getHost();
|
||||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||||
$envs_dict['SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
||||||
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyFqdn);
|
$envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString());
|
||||||
|
$envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1686,17 +1690,18 @@ private function generate_buildtime_environment_variables()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
|
// 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) {
|
foreach ($domains as $forServiceName => $domain) {
|
||||||
$parsedDomain = data_get($domain, 'domain');
|
$parsedDomain = composeDomainEntryString($domain);
|
||||||
if (filled($parsedDomain)) {
|
if (filled($parsedDomain)) {
|
||||||
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
||||||
$coolifyUrl = Url::fromString($parsedDomain);
|
$coolifyUrl = Url::fromString($parsedDomain);
|
||||||
$coolifyScheme = $coolifyUrl->getScheme();
|
$coolifyScheme = $coolifyUrl->getScheme();
|
||||||
$coolifyFqdn = $coolifyUrl->getHost();
|
$coolifyFqdn = $coolifyUrl->getHost();
|
||||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||||
$envs_dict['SERVICE_URL_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
||||||
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($coolifyFqdn);
|
$envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString());
|
||||||
|
$envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -301,12 +301,7 @@ public function mount()
|
||||||
}
|
}
|
||||||
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
$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
|
// Convert service names with dots and dashes to use underscores for HTML form binding
|
||||||
$sanitizedDomains = [];
|
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
|
||||||
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
|
|
||||||
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
|
|
||||||
$sanitizedDomains[$sanitizedKey] = $domain;
|
|
||||||
}
|
|
||||||
$this->parsedServiceDomains = $sanitizedDomains;
|
|
||||||
|
|
||||||
$this->customLabels = $this->application->parseContainerLabels();
|
$this->customLabels = $this->application->parseContainerLabels();
|
||||||
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) {
|
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) {
|
||||||
|
|
@ -520,12 +515,7 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res
|
||||||
|
|
||||||
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
|
$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
|
// Convert service names with dots and dashes to use underscores for HTML form binding
|
||||||
$sanitizedDomains = [];
|
$this->parsedServiceDomains = $this->sanitizeParsedServiceDomainsForForm($this->parsedServiceDomains);
|
||||||
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
|
|
||||||
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
|
|
||||||
$sanitizedDomains[$sanitizedKey] = $domain;
|
|
||||||
}
|
|
||||||
$this->parsedServiceDomains = $sanitizedDomains;
|
|
||||||
|
|
||||||
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
|
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
|
||||||
$this->dispatch('compose_loaded');
|
$this->dispatch('compose_loaded');
|
||||||
|
|
@ -551,22 +541,14 @@ public function generateDomain(string $serviceName)
|
||||||
|
|
||||||
$uuid = new_public_id();
|
$uuid = new_public_id();
|
||||||
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
|
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
|
||||||
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
|
$sanitizedKey = normalizeComposeServiceName($serviceName);
|
||||||
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
|
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
|
||||||
|
|
||||||
// Convert back to original service names for storage
|
// Convert back to original service names for storage
|
||||||
$originalDomains = [];
|
$originalDomains = [];
|
||||||
|
$composeServiceNames = collect(data_get($this->parsedServices, 'services', []))->keys()->map(fn ($name) => (string) $name)->values()->all();
|
||||||
foreach ($this->parsedServiceDomains as $key => $value) {
|
foreach ($this->parsedServiceDomains as $key => $value) {
|
||||||
// Find the original service name by checking parsed services
|
$originalServiceName = findComposeServiceName((string) $key, $composeServiceNames) ?? (string) $key;
|
||||||
$originalServiceName = $key;
|
|
||||||
if (isset($this->parsedServices['services'])) {
|
|
||||||
foreach ($this->parsedServices['services'] as $originalName => $service) {
|
|
||||||
if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) {
|
|
||||||
$originalServiceName = $originalName;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$originalDomains[$originalServiceName] = $value;
|
$originalDomains[$originalServiceName] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -858,9 +840,10 @@ public function submit($showToaster = true)
|
||||||
foreach ($this->parsedServiceDomains as $serviceName => $service) {
|
foreach ($this->parsedServiceDomains as $serviceName => $service) {
|
||||||
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
|
$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')) {
|
if ($this->application->isDirty('docker_compose_domains')) {
|
||||||
foreach ($this->parsedServiceDomains as $service) {
|
foreach ($originalDomains as $service) {
|
||||||
$domain = data_get($service, 'domain');
|
$domain = data_get($service, 'domain');
|
||||||
if ($domain) {
|
if ($domain) {
|
||||||
if (! validateDNSEntry($domain, $this->application->destination->server)) {
|
if (! validateDNSEntry($domain, $this->application->destination->server)) {
|
||||||
|
|
@ -985,4 +968,58 @@ public function getDockerComposeStartCommandPreviewProperty(): string
|
||||||
'{workdir}/.env'
|
'{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 @@ public function save()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
|
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
|
||||||
|
$this->persistPreviewDomain($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->dispatch('update_links');
|
$this->dispatch('update_links');
|
||||||
$this->dispatch('success', 'Domain saved.');
|
$this->dispatch('success', 'Domain saved.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
@ -58,12 +52,8 @@ public function generate()
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->preview->application);
|
$this->authorize('update', $this->preview->application);
|
||||||
|
|
||||||
$domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []);
|
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
|
||||||
$domain = $domains->first(function ($_, $key) {
|
$domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName);
|
||||||
return $key === $this->serviceName;
|
|
||||||
});
|
|
||||||
|
|
||||||
$domain_string = data_get($domain, 'domain');
|
|
||||||
|
|
||||||
// If no domain is set in the main application, generate a default domain
|
// If no domain is set in the main application, generate a default domain
|
||||||
if (empty($domain_string)) {
|
if (empty($domain_string)) {
|
||||||
|
|
@ -111,14 +101,8 @@ public function generate()
|
||||||
$preview_fqdn = implode(',', $preview_fqdns);
|
$preview_fqdn = implode(',', $preview_fqdns);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the generated domain
|
|
||||||
$this->domain = $preview_fqdn;
|
$this->domain = $preview_fqdn;
|
||||||
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
|
$this->persistPreviewDomain($this->domain);
|
||||||
$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->dispatch('update_links');
|
$this->dispatch('update_links');
|
||||||
$this->dispatch('success', 'Domain generated.');
|
$this->dispatch('success', 'Domain generated.');
|
||||||
|
|
@ -126,4 +110,56 @@ public function generate()
|
||||||
return handleError($e, $this);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2082,34 +2082,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
|
||||||
$this->save();
|
$this->save();
|
||||||
$parsedServices = $this->parse();
|
$parsedServices = $this->parse();
|
||||||
if ($this->docker_compose_domains) {
|
if ($this->docker_compose_domains) {
|
||||||
$decoded = json_decode($this->docker_compose_domains, true);
|
$this->reconcileDockerComposeDomains($parsedServices);
|
||||||
$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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -2126,6 +2099,31 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
public function parseContainerLabels(?ApplicationPreview $preview = null)
|
||||||
{
|
{
|
||||||
$customLabels = data_get($this, 'custom_labels');
|
$customLabels = data_get($this, 'custom_labels');
|
||||||
|
|
|
||||||
|
|
@ -124,34 +124,55 @@ public function generate_preview_fqdn()
|
||||||
|
|
||||||
public function generate_preview_fqdn_compose()
|
public function generate_preview_fqdn_compose()
|
||||||
{
|
{
|
||||||
$services = collect(json_decode($this->application->docker_compose_domains)) ?? collect();
|
$applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: [];
|
||||||
$docker_compose_domains = data_get($this, 'docker_compose_domains');
|
$previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: [];
|
||||||
$docker_compose_domains = json_decode($docker_compose_domains, true) ?? [];
|
|
||||||
|
|
||||||
// Get all services from the parsed compose file to ensure all services have entries
|
$composeServiceNames = $this->composeServiceNamesForPreview();
|
||||||
$parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id);
|
// Canonical compose names first; collapse leftover domain-map twins when parse is empty/missing.
|
||||||
if (isset($parsedServices['services'])) {
|
$domainKeys = collect(array_keys($applicationDomains))
|
||||||
foreach ($parsedServices['services'] as $serviceName => $service) {
|
->merge(array_keys($previewDomains))
|
||||||
if (! isDatabaseImage(data_get($service, 'image'))) {
|
->map(fn ($name) => (string) $name);
|
||||||
// Remove PR suffix from service name to get original service name
|
$unmappedDomainKeys = $domainKeys
|
||||||
$originalServiceName = str($serviceName)->replaceLast('-pr-'.$this->pull_request_id, '')->toString();
|
->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
|
$applicationDomains = rekeyComposeDomainsToServiceNames($applicationDomains, $knownServiceNames);
|
||||||
if (! $services->has($originalServiceName)) {
|
$previewDomains = rekeyComposeDomainsToServiceNames($previewDomains, $knownServiceNames);
|
||||||
$services->put($originalServiceName, ['domain' => '']);
|
|
||||||
}
|
// 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) {
|
$serviceNames = collect(array_keys($applicationDomains))
|
||||||
$domain_string = data_get($service_config, 'domain');
|
->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
|
// If domain string is empty or null, don't auto-generate domain
|
||||||
// Only generate domains when main app already has domains set
|
// Only generate domains when main app already has domains set
|
||||||
if (empty($domain_string)) {
|
if (empty($domain_string)) {
|
||||||
// Ensure service has an empty domain entry for form binding
|
$docker_compose_domains = putComposeServiceDomain(
|
||||||
$docker_compose_domains[$service_name]['domain'] = '';
|
$docker_compose_domains,
|
||||||
|
$service_name,
|
||||||
|
'',
|
||||||
|
$serviceNames,
|
||||||
|
);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -180,19 +201,22 @@ public function generate_preview_fqdn_compose()
|
||||||
$preview_domains[] = $preview_fqdn;
|
$preview_domains[] = $preview_fqdn;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($preview_domains)) {
|
$docker_compose_domains = putComposeServiceDomain(
|
||||||
$docker_compose_domains[$service_name]['domain'] = implode(',', $preview_domains);
|
$docker_compose_domains,
|
||||||
} else {
|
$service_name,
|
||||||
// Ensure service has an empty domain entry for form binding
|
! empty($preview_domains) ? implode(',', $preview_domains) : '',
|
||||||
$docker_compose_domains[$service_name]['domain'] = '';
|
$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);
|
$this->docker_compose_domains = json_encode($docker_compose_domains);
|
||||||
|
|
||||||
// Populate fqdn from generated domains so webhook notifications can read it
|
// Populate fqdn from generated domains so webhook notifications can read it
|
||||||
$allDomains = collect($docker_compose_domains)
|
$allDomains = collect($docker_compose_domains)
|
||||||
->pluck('domain')
|
->map(fn ($entry) => composeDomainEntryString($entry))
|
||||||
->filter(fn ($d) => ! empty($d))
|
->filter(fn ($d) => ! empty($d))
|
||||||
->flatMap(fn ($d) => explode(',', $d))
|
->flatMap(fn ($d) => explode(',', $d))
|
||||||
->implode(',');
|
->implode(',');
|
||||||
|
|
@ -201,4 +225,31 @@ public function generate_preview_fqdn_compose()
|
||||||
|
|
||||||
$this->save();
|
$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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,37 @@
|
||||||
use Spatie\Url\Url;
|
use Spatie\Url\Url;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable short hash for compose service names used in Traefik router/service IDs.
|
||||||
|
* Keeps api.test vs api-test distinct after label-safe normalization.
|
||||||
|
*/
|
||||||
|
function traefikServiceNameHash(string $serviceName): string
|
||||||
|
{
|
||||||
|
return substr(md5($serviceName), 0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label-safe Traefik router/service name segment for a compose service.
|
||||||
|
* Dots (and other non [a-zA-Z0-9-]) break Docker/Traefik label paths; always append a hash
|
||||||
|
* so normalized names like api.test and api-test never collide.
|
||||||
|
*/
|
||||||
|
function traefikSafeServiceNameSegment(string $serviceName): string
|
||||||
|
{
|
||||||
|
$normalized = str($serviceName)
|
||||||
|
->replace('.', '-')
|
||||||
|
->replace('_', '-')
|
||||||
|
->replaceMatches('/[^a-zA-Z0-9-]+/', '-')
|
||||||
|
->replaceMatches('/-+/', '-')
|
||||||
|
->trim('-')
|
||||||
|
->toString();
|
||||||
|
|
||||||
|
if ($normalized === '') {
|
||||||
|
$normalized = 'service';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized.'-'.traefikServiceNameHash($serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
|
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
|
||||||
{
|
{
|
||||||
$containers = collect([]);
|
$containers = collect([]);
|
||||||
|
|
@ -507,8 +538,10 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||||
$http_label = "http-{$loop}-{$uuid}";
|
$http_label = "http-{$loop}-{$uuid}";
|
||||||
$https_label = "https-{$loop}-{$uuid}";
|
$https_label = "https-{$loop}-{$uuid}";
|
||||||
if ($service_name) {
|
if ($service_name) {
|
||||||
$http_label = "http-{$loop}-{$uuid}-{$service_name}";
|
// Dots in service names split Traefik label paths; hash avoids api.test/api-test collisions.
|
||||||
$https_label = "https-{$loop}-{$uuid}-{$service_name}";
|
$safeServiceName = traefikSafeServiceNameSegment($service_name);
|
||||||
|
$http_label = "http-{$loop}-{$uuid}-{$safeServiceName}";
|
||||||
|
$https_label = "https-{$loop}-{$uuid}-{$safeServiceName}";
|
||||||
}
|
}
|
||||||
if (str($image)->contains('ghost')) {
|
if (str($image)->contains('ghost')) {
|
||||||
$labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)");
|
$labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Models\Service;
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
|
@ -310,3 +311,281 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
|
||||||
'hasConflicts' => count($conflicts) > 0,
|
'hasConflicts' => count($conflicts) > 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a compose service name the way Coolify historically did for env var keys
|
||||||
|
* (hyphens and dots → underscores). Used for comparison and SERVICE_* env names only —
|
||||||
|
* docker_compose_domains keys should use the original compose service name.
|
||||||
|
*/
|
||||||
|
function normalizeComposeServiceName(string $serviceName): string
|
||||||
|
{
|
||||||
|
return str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a candidate key (original or legacy-normalized) to the original compose service name.
|
||||||
|
*
|
||||||
|
* @param iterable<int|string, mixed> $serviceNames
|
||||||
|
*/
|
||||||
|
function findComposeServiceName(string $candidate, iterable $serviceNames): ?string
|
||||||
|
{
|
||||||
|
$names = collect($serviceNames)->map(fn ($name) => (string) $name)->values();
|
||||||
|
|
||||||
|
if ($names->containsStrict($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = normalizeComposeServiceName($candidate);
|
||||||
|
$matches = $names->filter(
|
||||||
|
fn ($name) => normalizeComposeServiceName($name) === $normalized
|
||||||
|
)->values();
|
||||||
|
|
||||||
|
return $matches->count() === 1 ? $matches->first() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse domain-map keys that only differ by hyphen/dot/underscore into one preferred name.
|
||||||
|
* Prefers a key that still has `-` or `.` over a fully underscore-normalized twin.
|
||||||
|
*
|
||||||
|
* @param iterable<int|string, mixed> $domainKeys
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function preferredComposeServiceNamesFromDomainKeys(iterable $domainKeys): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
foreach ($domainKeys as $key) {
|
||||||
|
$key = (string) $key;
|
||||||
|
$groups[normalizeComposeServiceName($key)][] = $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
$preferred = [];
|
||||||
|
foreach ($groups as $normalized => $keys) {
|
||||||
|
$keys = array_values(array_unique($keys));
|
||||||
|
$withSeparators = array_values(array_filter(
|
||||||
|
$keys,
|
||||||
|
fn (string $key) => $key !== $normalized
|
||||||
|
));
|
||||||
|
$preferred[] = $withSeparators[0] ?? $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read domain string for a compose service from docker_compose_domains.
|
||||||
|
* Prefers a filled domain on the requested key; falls back to any filled twin
|
||||||
|
* (legacy underscore keys). Blank entries do not shadow filled twins.
|
||||||
|
* Uses collection key access (not dotted data_get) so names like "api.test" work.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Collection<string, mixed> $domains
|
||||||
|
*/
|
||||||
|
function getComposeServiceDomainString(array|Collection $domains, string $serviceName): ?string
|
||||||
|
{
|
||||||
|
$domains = collect($domains);
|
||||||
|
$normalized = normalizeComposeServiceName($serviceName);
|
||||||
|
$matches = [];
|
||||||
|
|
||||||
|
foreach ($domains as $key => $entry) {
|
||||||
|
$key = (string) $key;
|
||||||
|
if ($key !== $serviceName
|
||||||
|
&& $key !== $normalized
|
||||||
|
&& normalizeComposeServiceName($key) !== $normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matches[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'domain' => composeDomainEntryString($entry),
|
||||||
|
'is_requested' => $key === $serviceName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matches === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filled = array_values(array_filter(
|
||||||
|
$matches,
|
||||||
|
fn (array $match) => filled($match['domain'])
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($filled !== []) {
|
||||||
|
foreach ($filled as $match) {
|
||||||
|
if ($match['is_requested']) {
|
||||||
|
return $match['domain'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filled[0]['domain'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
if ($match['is_requested']) {
|
||||||
|
return $match['domain'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matches[0]['domain'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeDomainEntryString(mixed $entry): ?string
|
||||||
|
{
|
||||||
|
if (is_object($entry)) {
|
||||||
|
$entry = (array) $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($entry)) {
|
||||||
|
return is_string($entry) ? $entry : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = $entry['domain'] ?? null;
|
||||||
|
|
||||||
|
return is_string($domain) ? $domain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which domain string wins when merging twin compose domain keys.
|
||||||
|
* Filled values always beat blanks; when both are filled, prefer the canonical key.
|
||||||
|
*/
|
||||||
|
function preferComposeDomainValue(
|
||||||
|
mixed $existingDomain,
|
||||||
|
bool $existingIsCanonical,
|
||||||
|
mixed $incomingDomain,
|
||||||
|
bool $incomingIsCanonical,
|
||||||
|
): mixed {
|
||||||
|
$existingFilled = filled($existingDomain);
|
||||||
|
$incomingFilled = filled($incomingDomain);
|
||||||
|
|
||||||
|
if ($existingFilled && ! $incomingFilled) {
|
||||||
|
return $existingDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($incomingFilled && ! $existingFilled) {
|
||||||
|
return $incomingDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingFilled && $incomingFilled) {
|
||||||
|
if ($incomingIsCanonical && ! $existingIsCanonical) {
|
||||||
|
return $incomingDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existingDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both blank: keep canonical slot when possible.
|
||||||
|
if ($incomingIsCanonical) {
|
||||||
|
return $incomingDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existingDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rekey docker_compose_domains to original compose service names.
|
||||||
|
* Merges legacy underscore/dot twin keys onto the canonical compose name.
|
||||||
|
* Never lets an empty canonical key wipe a filled legacy twin.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Collection<string, mixed> $domains
|
||||||
|
* @param iterable<int|string, mixed> $serviceNames
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function rekeyComposeDomainsToServiceNames(array|Collection $domains, iterable $serviceNames): array
|
||||||
|
{
|
||||||
|
$domains = collect($domains);
|
||||||
|
$serviceNames = collect($serviceNames)->map(fn ($name) => (string) $name)->values();
|
||||||
|
$rekeyed = [];
|
||||||
|
$canonicalDomainSources = [];
|
||||||
|
|
||||||
|
foreach ($domains as $key => $value) {
|
||||||
|
$key = (string) $key;
|
||||||
|
$original = findComposeServiceName($key, $serviceNames) ?? $key;
|
||||||
|
$isCanonical = $key === $original;
|
||||||
|
|
||||||
|
if (is_object($value)) {
|
||||||
|
$value = (array) $value;
|
||||||
|
}
|
||||||
|
if (! is_array($value)) {
|
||||||
|
$value = ['domain' => $value];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($rekeyed[$original])) {
|
||||||
|
$rekeyed[$original] = $value;
|
||||||
|
$canonicalDomainSources[$original] = $isCanonical;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingDomain = $rekeyed[$original]['domain'] ?? null;
|
||||||
|
$incomingDomain = $value['domain'] ?? null;
|
||||||
|
$rekeyed[$original] = array_merge($rekeyed[$original], $value);
|
||||||
|
$rekeyed[$original]['domain'] = preferComposeDomainValue(
|
||||||
|
$existingDomain,
|
||||||
|
$canonicalDomainSources[$original],
|
||||||
|
$incomingDomain,
|
||||||
|
$isCanonical,
|
||||||
|
);
|
||||||
|
$canonicalDomainSources[$original] = $canonicalDomainSources[$original] || $isCanonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rekeyed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a SERVICE_FQDN_ / SERVICE_URL_ env fragment to a ServiceApplication by compose name.
|
||||||
|
* Handles legacy underscore fragments and dotted compose names (api.test vs API_TEST).
|
||||||
|
*/
|
||||||
|
function findServiceApplicationForEnvName(Service $resource, string $envServiceName): ?ServiceApplication
|
||||||
|
{
|
||||||
|
$names = $resource->applications()->pluck('name');
|
||||||
|
$resolved = findComposeServiceName($envServiceName, $names)
|
||||||
|
?? findComposeServiceName(str($envServiceName)->replace('_', '-')->toString(), $names);
|
||||||
|
|
||||||
|
if ($resolved === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resource->applications()->where('name', $resolved)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put/update a service domain entry under the original compose service name,
|
||||||
|
* removing legacy twin keys that resolve unambiguously to the same name.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Collection<string, mixed> $domains
|
||||||
|
* @param iterable<int|string, mixed> $serviceNames
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function putComposeServiceDomain(
|
||||||
|
array|Collection $domains,
|
||||||
|
string $serviceName,
|
||||||
|
?string $domainString,
|
||||||
|
iterable $serviceNames = [],
|
||||||
|
): array {
|
||||||
|
$domains = collect($domains)->all();
|
||||||
|
$names = collect($serviceNames)->map(fn ($name) => (string) $name);
|
||||||
|
$storageKey = findComposeServiceName($serviceName, $names) ?? $serviceName;
|
||||||
|
|
||||||
|
$merged = ['domain' => $domainString];
|
||||||
|
foreach (array_keys($domains) as $key) {
|
||||||
|
$key = (string) $key;
|
||||||
|
if ($key !== $storageKey && findComposeServiceName($key, $names) !== $storageKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $domains[$key];
|
||||||
|
if (is_object($existing)) {
|
||||||
|
$existing = (array) $existing;
|
||||||
|
}
|
||||||
|
if (is_array($existing)) {
|
||||||
|
$merged = array_merge($existing, $merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key !== $storageKey) {
|
||||||
|
unset($domains[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$domains[$storageKey] = $merged;
|
||||||
|
|
||||||
|
return $domains;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Stringable;
|
||||||
use Spatie\Url\Url;
|
use Spatie\Url\Url;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
|
@ -462,7 +463,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
|
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
|
||||||
$path = $value->value();
|
$path = $value->value();
|
||||||
if ($path !== '/') {
|
if ($path !== '/') {
|
||||||
$fqdn = "$fqdn$path";
|
$fqdn = "$fqdn$path";
|
||||||
|
|
@ -507,21 +508,15 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
// Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations.
|
// 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_'))) {
|
if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
|
||||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||||
$normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value();
|
$normalizedServiceName = normalizeComposeServiceName((string) $parsed['service_name']);
|
||||||
$serviceExists = false;
|
$originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services));
|
||||||
foreach (array_keys($services) as $serviceNameKey) {
|
if ($originalServiceName !== null) {
|
||||||
if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) {
|
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
|
||||||
$serviceExists = true;
|
$domainExists = getComposeServiceDomainString($domains, $originalServiceName);
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($serviceExists) {
|
|
||||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
|
|
||||||
$domainExists = data_get($domains->get($normalizedServiceName), 'domain');
|
|
||||||
if (is_null($domainExists)) {
|
if (is_null($domainExists)) {
|
||||||
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
|
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
|
||||||
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
|
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
|
||||||
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
|
||||||
$path = $value->value();
|
$path = $value->value();
|
||||||
if ($path !== '/') {
|
if ($path !== '/') {
|
||||||
$domainValue = "$domainValue$path";
|
$domainValue = "$domainValue$path";
|
||||||
|
|
@ -530,8 +525,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
if ($parsed['port'] && is_numeric($parsed['port'])) {
|
if ($parsed['port'] && is_numeric($parsed['port'])) {
|
||||||
$domainValue = "$domainValue:{$parsed['port']}";
|
$domainValue = "$domainValue:{$parsed['port']}";
|
||||||
}
|
}
|
||||||
$domains->put($normalizedServiceName, ['domain' => $domainValue]);
|
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
|
||||||
$resource->docker_compose_domains = $domains->toJson();
|
$domains,
|
||||||
|
$originalServiceName,
|
||||||
|
$domainValue,
|
||||||
|
array_keys($services),
|
||||||
|
));
|
||||||
$resource->save();
|
$resource->save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -568,8 +567,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
}
|
}
|
||||||
|
|
||||||
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||||
// Always normalize service names to match docker_compose_domains lookup
|
// Env var SERVICE_* names still use underscores; domain map keys use original compose names.
|
||||||
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
$serviceName = normalizeComposeServiceName((string) $serviceName);
|
||||||
|
|
||||||
// Generate BOTH FQDN & URL
|
// Generate BOTH FQDN & URL
|
||||||
$fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version);
|
$fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||||
|
|
@ -630,29 +629,24 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($resource->build_pack === 'dockercompose') {
|
if ($resource->build_pack === 'dockercompose') {
|
||||||
// Check if a service with this name actually exists
|
// Match env-derived name to the real compose service key (hyphens/dots preserved).
|
||||||
$serviceExists = false;
|
$composeServiceName = findComposeServiceName($serviceName, array_keys($services));
|
||||||
foreach ($services as $serviceNameKey => $service) {
|
|
||||||
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
|
|
||||||
if ($transformedServiceName === $serviceName) {
|
|
||||||
$serviceExists = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add domain if the service exists
|
// Only add domain if the service exists
|
||||||
if ($serviceExists) {
|
if ($composeServiceName !== null) {
|
||||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
|
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
|
||||||
$domainExists = data_get($domains->get($serviceName), 'domain');
|
$domainExists = getComposeServiceDomainString($domains, $composeServiceName);
|
||||||
|
|
||||||
// Update domain using URL with port if applicable
|
// Update domain using URL with port if applicable
|
||||||
$domainValue = $port ? $urlWithPort : $url;
|
$domainValue = $port ? $urlWithPort : $url;
|
||||||
|
|
||||||
if (is_null($domainExists)) {
|
if (is_null($domainExists)) {
|
||||||
$domains->put($serviceName, [
|
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
|
||||||
'domain' => $domainValue,
|
$domains,
|
||||||
]);
|
$composeServiceName,
|
||||||
$resource->docker_compose_domains = $domains->toJson();
|
$domainValue,
|
||||||
|
array_keys($services),
|
||||||
|
));
|
||||||
$resource->save();
|
$resource->save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1186,21 +1180,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
|
|
||||||
if ($isPullRequest) {
|
if ($isPullRequest) {
|
||||||
$preview = $resource->previews()->find($preview_id);
|
$preview = $resource->previews()->find($preview_id);
|
||||||
$domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]);
|
$domains = collect(json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []);
|
||||||
} else {
|
} else {
|
||||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process domains for dockercompose applications to prevent SERVICE variable recreation
|
// Only process domains for dockercompose applications to prevent SERVICE variable recreation
|
||||||
if ($resource->build_pack !== 'dockercompose') {
|
if ($resource->build_pack !== 'dockercompose') {
|
||||||
$domains = collect([]);
|
$domains = collect([]);
|
||||||
}
|
}
|
||||||
$changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
// Prefer original compose service key; fall back to legacy underscore storage keys.
|
||||||
$fqdns = data_get($domains, "$changedServiceName.domain");
|
$fqdns = getComposeServiceDomainString($domains, (string) $serviceName);
|
||||||
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
|
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
|
||||||
if ($resource->build_pack === 'dockercompose') {
|
if ($resource->build_pack === 'dockercompose') {
|
||||||
foreach ($domains as $forServiceName => $domain) {
|
foreach ($domains as $forServiceName => $domain) {
|
||||||
$parsedDomain = data_get($domain, 'domain');
|
$parsedDomain = composeDomainEntryString($domain);
|
||||||
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
|
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
|
||||||
|
|
||||||
if (filled($parsedDomain)) {
|
if (filled($parsedDomain)) {
|
||||||
|
|
@ -1209,12 +1203,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
$coolifyScheme = $coolifyUrl->getScheme();
|
$coolifyScheme = $coolifyUrl->getScheme();
|
||||||
$coolifyFqdn = $coolifyUrl->getHost();
|
$coolifyFqdn = $coolifyUrl->getHost();
|
||||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||||
$coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
||||||
$coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
|
$coolifyEnvironments->put('SERVICE_URL_'.$serviceEnvKey, $coolifyUrl->__toString());
|
||||||
|
$coolifyEnvironments->put('SERVICE_FQDN_'.$serviceEnvKey, $coolifyFqdn);
|
||||||
$resource->environment_variables()->updateOrCreate([
|
$resource->environment_variables()->updateOrCreate([
|
||||||
'resourceable_type' => Application::class,
|
'resourceable_type' => Application::class,
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
|
'key' => 'SERVICE_URL_'.$serviceEnvKey,
|
||||||
], [
|
], [
|
||||||
'value' => $coolifyUrl->__toString(),
|
'value' => $coolifyUrl->__toString(),
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
|
|
@ -1222,7 +1217,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
$resource->environment_variables()->updateOrCreate([
|
$resource->environment_variables()->updateOrCreate([
|
||||||
'resourceable_type' => Application::class,
|
'resourceable_type' => Application::class,
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
|
'key' => 'SERVICE_FQDN_'.$serviceEnvKey,
|
||||||
], [
|
], [
|
||||||
'value' => $coolifyFqdn,
|
'value' => $coolifyFqdn,
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
|
|
@ -1248,9 +1243,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
$fqdns = str($fqdns)->explode(',');
|
$fqdns = str($fqdns)->explode(',');
|
||||||
if ($isPullRequest) {
|
if ($isPullRequest) {
|
||||||
$preview = $resource->previews()->find($preview_id);
|
$preview = $resource->previews()->find($preview_id);
|
||||||
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
|
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []);
|
||||||
if ($docker_compose_domains->count() > 0) {
|
if ($docker_compose_domains->count() > 0) {
|
||||||
$found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain");
|
$found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName);
|
||||||
if ($found_fqdn) {
|
if ($found_fqdn) {
|
||||||
$fqdns = collect($found_fqdn);
|
$fqdns = collect($found_fqdn);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1291,15 +1286,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
$isDatabase = isDatabaseImage($image, $service);
|
$isDatabase = isDatabaseImage($image, $service);
|
||||||
// Add COOLIFY_FQDN & COOLIFY_URL to environment
|
// Add COOLIFY_FQDN & COOLIFY_URL to environment
|
||||||
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
||||||
$fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
|
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(','));
|
||||||
return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
|
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(','));
|
||||||
});
|
|
||||||
$coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(','));
|
|
||||||
|
|
||||||
$urls = $fqdns->map(function ($fqdn) {
|
|
||||||
return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
|
|
||||||
});
|
|
||||||
$coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
|
|
||||||
}
|
}
|
||||||
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
|
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
|
||||||
if ($environment->count() > 0) {
|
if ($environment->count() > 0) {
|
||||||
|
|
@ -1788,8 +1776,10 @@ function serviceParser(Service $resource): Collection
|
||||||
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||||
$url = generateUrl($server, "$fqdnFor-$uuid");
|
$url = generateUrl($server, "$fqdnFor-$uuid");
|
||||||
} elseif ($isServiceApplication) {
|
} elseif ($isServiceApplication) {
|
||||||
$fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
// FQDN may be a comma-separated list; use the first entry (same as updateCompose).
|
||||||
$url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
$firstFqdn = firstDomainFromList($savedService->fqdn);
|
||||||
|
$fqdn = getFqdnWithoutPort($firstFqdn);
|
||||||
|
$url = $fqdn;
|
||||||
} else {
|
} else {
|
||||||
// For ServiceDatabase, generate fqdn/url without saving to the model
|
// For ServiceDatabase, generate fqdn/url without saving to the model
|
||||||
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||||
|
|
@ -1801,7 +1791,7 @@ function serviceParser(Service $resource): Collection
|
||||||
// Strip scheme for environment variable values
|
// Strip scheme for environment variable values
|
||||||
$fqdnValueForEnv = str($fqdn)->after('://')->value();
|
$fqdnValueForEnv = str($fqdn)->after('://')->value();
|
||||||
|
|
||||||
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
|
||||||
$path = $value->value();
|
$path = $value->value();
|
||||||
if ($path !== '/') {
|
if ($path !== '/') {
|
||||||
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
|
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
|
||||||
|
|
@ -1905,12 +1895,12 @@ function serviceParser(Service $resource): Collection
|
||||||
->where('key', 'LIKE', $key->value().'_%')
|
->where('key', 'LIKE', $key->value().'_%')
|
||||||
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
|
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
|
||||||
->exists();
|
->exists();
|
||||||
$serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
|
$serviceExists = findServiceApplicationForEnvName($resource, (string) $fqdnFor);
|
||||||
// Check if FQDN already has a port set (contains ':' after the domain)
|
// Check if FQDN already has a port set (contains ':' after the domain)
|
||||||
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
|
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
|
||||||
// Only set FQDN if it's for the current service being processed (prevent race conditions)
|
// Only set FQDN if it's for the current service being processed (prevent race conditions)
|
||||||
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
|
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
|
||||||
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
|
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService) {
|
||||||
// Save URL otherwise it won't work.
|
// Save URL otherwise it won't work.
|
||||||
$serviceExists->fqdn = $url;
|
$serviceExists->fqdn = $url;
|
||||||
$serviceExists->save();
|
$serviceExists->save();
|
||||||
|
|
@ -1950,12 +1940,12 @@ function serviceParser(Service $resource): Collection
|
||||||
->where('key', 'LIKE', $key->value().'_%')
|
->where('key', 'LIKE', $key->value().'_%')
|
||||||
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
|
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
|
||||||
->exists();
|
->exists();
|
||||||
$serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
|
$serviceExists = findServiceApplicationForEnvName($resource, (string) $urlFor);
|
||||||
// Check if FQDN already has a port set (contains ':' after the domain)
|
// Check if FQDN already has a port set (contains ':' after the domain)
|
||||||
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
|
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
|
||||||
// Only set FQDN if it's for the current service being processed (prevent race conditions)
|
// Only set FQDN if it's for the current service being processed (prevent race conditions)
|
||||||
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
|
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
|
||||||
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
|
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService) {
|
||||||
$serviceExists->fqdn = $url;
|
$serviceExists->fqdn = $url;
|
||||||
$serviceExists->save();
|
$serviceExists->save();
|
||||||
}
|
}
|
||||||
|
|
@ -2561,14 +2551,8 @@ function serviceParser(Service $resource): Collection
|
||||||
|
|
||||||
// Add COOLIFY_FQDN & COOLIFY_URL to environment
|
// Add COOLIFY_FQDN & COOLIFY_URL to environment
|
||||||
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
|
||||||
$fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
|
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(','));
|
||||||
return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
|
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(','));
|
||||||
});
|
|
||||||
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdnsWithoutPort->implode(','));
|
|
||||||
$urls = $fqdns->map(function ($fqdn): Stringable {
|
|
||||||
return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
|
|
||||||
});
|
|
||||||
$coolifyEnvironments->put('COOLIFY_URL', $urls->implode(','));
|
|
||||||
}
|
}
|
||||||
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
|
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
|
||||||
if ($environment->count() > 0) {
|
if ($environment->count() > 0) {
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ function replaceVariables(string $variable): Stringable
|
||||||
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)
|
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if ($oneService->getMorphClass() === \App\Models\Application::class) {
|
if ($oneService->getMorphClass() === Application::class) {
|
||||||
$workdir = $oneService->workdir();
|
$workdir = $oneService->workdir();
|
||||||
$server = $oneService->destination->server;
|
$server = $oneService->destination->server;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -204,7 +204,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
|
||||||
instant_remote_process(["mkdir -p $fileLocation"], $server);
|
instant_remote_process(["mkdir -p $fileLocation"], $server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return handleError($e);
|
return handleError($e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +214,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
||||||
$name = data_get($resource, 'name');
|
$name = data_get($resource, 'name');
|
||||||
$dockerComposeRaw = data_get($resource, 'service.docker_compose_raw');
|
$dockerComposeRaw = data_get($resource, 'service.docker_compose_raw');
|
||||||
if (! $dockerComposeRaw) {
|
if (! $dockerComposeRaw) {
|
||||||
throw new \Exception('No compose file found or not a valid YAML file.');
|
throw new Exception('No compose file found or not a valid YAML file.');
|
||||||
}
|
}
|
||||||
$dockerCompose = Yaml::parse($dockerComposeRaw);
|
$dockerCompose = Yaml::parse($dockerComposeRaw);
|
||||||
|
|
||||||
|
|
@ -325,22 +325,13 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($resource->fqdn) {
|
if ($resource->fqdn) {
|
||||||
$resourceFqdns = str($resource->fqdn)->explode(',');
|
$firstFqdn = firstDomainFromList($resource->fqdn);
|
||||||
$resourceFqdns = $resourceFqdns->first();
|
$url = Url::fromString($firstFqdn);
|
||||||
$url = Url::fromString($resourceFqdns);
|
|
||||||
$port = $url->getPort();
|
$port = $url->getPort();
|
||||||
$path = $url->getPath();
|
|
||||||
|
|
||||||
// Prepare URL value (with scheme and host)
|
// Same helpers as application/service parsers (COOLIFY_URL / COOLIFY_FQDN).
|
||||||
$urlValue = $url->getScheme().'://'.$url->getHost();
|
$urlValue = getFqdnWithoutPort($firstFqdn);
|
||||||
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
|
$fqdnValue = getHostWithoutPort($firstFqdn);
|
||||||
|
|
||||||
// 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
|
// For each service name found in template, create BOTH SERVICE_URL and SERVICE_FQDN pairs
|
||||||
foreach ($serviceNamesToProcess as $serviceInfo) {
|
foreach ($serviceNamesToProcess as $serviceInfo) {
|
||||||
|
|
@ -396,7 +387,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return handleError($e);
|
return handleError($e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -495,7 +486,7 @@ function applyServiceApplicationPrerequisites(Service $service): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Log error but don't throw - prerequisites are nice-to-have, not critical
|
// Log error but don't throw - prerequisites are nice-to-have, not critical
|
||||||
Log::error('Failed to apply service application prerequisites', [
|
Log::error('Failed to apply service application prerequisites', [
|
||||||
'service_id' => $service->id,
|
'service_id' => $service->id,
|
||||||
|
|
|
||||||
|
|
@ -749,18 +749,71 @@ function base_ip(): string
|
||||||
|
|
||||||
return 'localhost';
|
return 'localhost';
|
||||||
}
|
}
|
||||||
function getFqdnWithoutPort(string $fqdn)
|
/**
|
||||||
|
* Parse a domain URL into scheme/host/path pieces used by COOLIFY_* and SERVICE_* env builders.
|
||||||
|
* Omits a bare "/" path so port re-append stays valid ("http://host:80" not "http://host/:80").
|
||||||
|
*
|
||||||
|
* @return array{scheme: string, host: string, path: string}|null
|
||||||
|
*/
|
||||||
|
function parseDomainUrlParts(string $fqdn): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$url = Url::fromString($fqdn);
|
$url = Url::fromString($fqdn);
|
||||||
$host = $url->getHost();
|
$host = $url->getHost();
|
||||||
$scheme = $url->getScheme();
|
if ($host === '') {
|
||||||
$path = $url->getPath();
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return "$scheme://$host$path";
|
$path = $url->getPath();
|
||||||
|
if ($path === '' || $path === '/') {
|
||||||
|
$path = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'scheme' => $url->getScheme(),
|
||||||
|
'host' => $host,
|
||||||
|
'path' => $path,
|
||||||
|
];
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute URL without port (and without a bare trailing slash).
|
||||||
|
* Used for COOLIFY_URL / SERVICE_URL base values.
|
||||||
|
*/
|
||||||
|
function getFqdnWithoutPort(string $fqdn): string
|
||||||
|
{
|
||||||
|
$parts = parseDomainUrlParts($fqdn);
|
||||||
|
if ($parts === null || $parts['scheme'] === '') {
|
||||||
|
// Spatie accepts bare hostnames (empty scheme). Do not invent "://host".
|
||||||
return $fqdn;
|
return $fqdn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $parts['scheme'].'://'.$parts['host'].$parts['path'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host (+ optional path) without scheme or port.
|
||||||
|
* Used for COOLIFY_FQDN / SERVICE_FQDN base values.
|
||||||
|
*/
|
||||||
|
function getHostWithoutPort(string $fqdn): string
|
||||||
|
{
|
||||||
|
$parts = parseDomainUrlParts($fqdn);
|
||||||
|
if ($parts === null) {
|
||||||
|
return $fqdn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts['host'].$parts['path'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First entry from a comma-separated FQDN list (service apps may store multiple).
|
||||||
|
*/
|
||||||
|
function firstDomainFromList(?string $fqdns): string
|
||||||
|
{
|
||||||
|
return trim((string) str($fqdns ?? '')->explode(',')->first());
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* If fqdn is set, return it, otherwise return public ip.
|
* If fqdn is set, return it, otherwise return public ip.
|
||||||
|
|
@ -2538,7 +2591,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
if ($env) {
|
if ($env) {
|
||||||
$env_url = Url::fromString($savedService->fqdn);
|
$env_url = Url::fromString($savedService->fqdn);
|
||||||
$env_port = $env_url->getPort();
|
$env_port = $env_url->getPort();
|
||||||
if ($env_port !== $predefinedPort) {
|
if ((int) $env_port !== (int) $predefinedPort) {
|
||||||
$env_url = $env_url->withPort($predefinedPort);
|
$env_url = $env_url->withPort($predefinedPort);
|
||||||
$savedService->fqdn = $env_url->__toString();
|
$savedService->fqdn = $env_url->__toString();
|
||||||
$savedService->save();
|
$savedService->save();
|
||||||
|
|
@ -2623,7 +2676,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
if ($env) {
|
if ($env) {
|
||||||
$env_url = Url::fromString($env->value);
|
$env_url = Url::fromString($env->value);
|
||||||
$env_port = $env_url->getPort();
|
$env_port = $env_url->getPort();
|
||||||
if ($env_port !== $predefinedPort) {
|
if ((int) $env_port !== (int) $predefinedPort) {
|
||||||
$env_url = $env_url->withPort($predefinedPort);
|
$env_url = $env_url->withPort($predefinedPort);
|
||||||
$savedService->fqdn = $env_url->__toString();
|
$savedService->fqdn = $env_url->__toString();
|
||||||
$savedService->save();
|
$savedService->save();
|
||||||
|
|
@ -3444,16 +3497,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
if ($resource->serviceType()) {
|
if ($resource->serviceType()) {
|
||||||
$fqdns = generateServiceSpecificFqdns($resource);
|
$fqdns = generateServiceSpecificFqdns($resource);
|
||||||
} else {
|
} else {
|
||||||
$domains = collect(json_decode($resource->docker_compose_domains)) ?? [];
|
$domains = json_decode($resource->docker_compose_domains ?: '[]', true) ?: [];
|
||||||
if ($domains) {
|
if ($domains) {
|
||||||
$fqdns = data_get($domains, "$serviceName.domain");
|
// Dual-read: original compose name or legacy underscore key.
|
||||||
|
$fqdns = getComposeServiceDomainString($domains, (string) $serviceName);
|
||||||
if ($fqdns) {
|
if ($fqdns) {
|
||||||
$fqdns = str($fqdns)->explode(',');
|
$fqdns = str($fqdns)->explode(',');
|
||||||
if ($pull_request_id !== 0) {
|
if ($pull_request_id !== 0) {
|
||||||
$preview = $resource->previews()->find($preview_id);
|
$preview = $resource->previews()->find($preview_id);
|
||||||
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
|
$docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: [];
|
||||||
if ($docker_compose_domains->count() > 0) {
|
if (count($docker_compose_domains) > 0) {
|
||||||
$found_fqdn = data_get($docker_compose_domains, "$serviceName.domain");
|
$found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName);
|
||||||
if ($found_fqdn) {
|
if ($found_fqdn) {
|
||||||
$fqdns = collect($found_fqdn);
|
$fqdns = collect($found_fqdn);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,86 @@
|
||||||
->and($domains['backend']['domain'])->toStartWith('http://');
|
->and($domains['backend']['domain'])->toStartWith('http://');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applicationParser stores domains under original hyphenated compose service names', function () {
|
||||||
|
$dockerCompose = <<<'YAML'
|
||||||
|
services:
|
||||||
|
another-service:
|
||||||
|
image: myapp/api:latest
|
||||||
|
environment:
|
||||||
|
- SERVICE_FQDN_ANOTHER_SERVICE=${API_URL}
|
||||||
|
analytics:
|
||||||
|
image: myapp/analytics:latest
|
||||||
|
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)->toBeArray()
|
||||||
|
->and($domains)->toHaveKey('another-service')
|
||||||
|
->and($domains)->not->toHaveKey('another_service')
|
||||||
|
->and($domains['another-service']['domain'])->toStartWith('http://');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applicationParser preserves legacy underscore domain keys by matching hyphenated services', function () {
|
||||||
|
$dockerCompose = <<<'YAML'
|
||||||
|
services:
|
||||||
|
another-service:
|
||||||
|
image: myapp/api:latest
|
||||||
|
environment:
|
||||||
|
- SERVICE_FQDN_ANOTHER_SERVICE=${API_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' => json_encode([
|
||||||
|
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
applicationParser($application);
|
||||||
|
|
||||||
|
$application->refresh();
|
||||||
|
$domains = json_decode($application->docker_compose_domains, true);
|
||||||
|
|
||||||
|
// Existing domain is preserved (not overwritten) even when stored under legacy underscore key.
|
||||||
|
expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose domain reconciliation preserves stored domains when parsing returns no services', function () {
|
||||||
|
$storedDomains = json_encode([
|
||||||
|
'frontend' => ['domain' => 'https://frontend.example.com'],
|
||||||
|
]);
|
||||||
|
$application = Application::factory()->create([
|
||||||
|
'environment_id' => $this->environment->id,
|
||||||
|
'destination_id' => $this->destination->id,
|
||||||
|
'destination_type' => StandaloneDocker::class,
|
||||||
|
'build_pack' => 'dockercompose',
|
||||||
|
'docker_compose_domains' => $storedDomains,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$method = new ReflectionMethod($application, 'reconcileDockerComposeDomains');
|
||||||
|
$method->invoke($application, ['services' => []]);
|
||||||
|
|
||||||
|
expect($application->fresh()->docker_compose_domains)->toBe($storedDomains);
|
||||||
|
});
|
||||||
|
|
||||||
test('applicationParser handles other docker compose domain shapes without regressions', function () {
|
test('applicationParser handles other docker compose domain shapes without regressions', function () {
|
||||||
$createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
|
$createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
|
||||||
return Application::factory()->create([
|
return Application::factory()->create([
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\Team;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
@ -57,6 +62,52 @@
|
||||||
expect($preview->fqdn)->toContain('api.example.com');
|
expect($preview->fqdn)->toContain('api.example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves distinct services whose normalized names collide when generating preview domains', function () {
|
||||||
|
$dockerCompose = <<<'YAML'
|
||||||
|
services:
|
||||||
|
api-test:
|
||||||
|
image: nginx:alpine
|
||||||
|
api.test:
|
||||||
|
image: nginx:alpine
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||||
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||||
|
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||||
|
$destination = StandaloneDocker::query()->where('server_id', $server->id)->first()
|
||||||
|
?? StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||||
|
|
||||||
|
$application = Application::factory()->create([
|
||||||
|
'build_pack' => 'dockercompose',
|
||||||
|
'environment_id' => $environment->id,
|
||||||
|
'destination_id' => $destination->id,
|
||||||
|
'destination_type' => StandaloneDocker::class,
|
||||||
|
'docker_compose_raw' => $dockerCompose,
|
||||||
|
'docker_compose_domains' => json_encode([
|
||||||
|
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||||
|
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preview = ApplicationPreview::create([
|
||||||
|
'application_id' => $application->id,
|
||||||
|
'pull_request_id' => 17,
|
||||||
|
'pull_request_html_url' => 'https://github.com/example/repo/pull/17',
|
||||||
|
'docker_compose_domains' => $application->docker_compose_domains,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preview->generate_preview_fqdn_compose();
|
||||||
|
$preview->refresh();
|
||||||
|
|
||||||
|
$domains = json_decode($preview->docker_compose_domains, true);
|
||||||
|
|
||||||
|
expect($domains)->toHaveKeys(['api-test', 'api.test'])
|
||||||
|
->and($domains)->toHaveCount(2)
|
||||||
|
->and($domains['api-test']['domain'])->toContain('hyphen.example.com')
|
||||||
|
->and($domains['api.test']['domain'])->toContain('dot.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
it('sets fqdn to null when no domains are configured', function () {
|
it('sets fqdn to null when no domains are configured', function () {
|
||||||
$application = Application::factory()->create([
|
$application = Application::factory()->create([
|
||||||
'build_pack' => 'dockercompose',
|
'build_pack' => 'dockercompose',
|
||||||
|
|
@ -78,3 +129,64 @@
|
||||||
|
|
||||||
expect($preview->fqdn)->toBeNull();
|
expect($preview->fqdn)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('collapses dashed and underscore twin domain keys into one preview service', function () {
|
||||||
|
$application = Application::factory()->create([
|
||||||
|
'build_pack' => 'dockercompose',
|
||||||
|
'docker_compose_domains' => json_encode([
|
||||||
|
'web-api' => ['domain' => 'https://web-api.example.com'],
|
||||||
|
'web_api' => ['domain' => ''],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preview = ApplicationPreview::create([
|
||||||
|
'application_id' => $application->id,
|
||||||
|
'pull_request_id' => 58,
|
||||||
|
'pull_request_html_url' => 'https://github.com/example/repo/pull/58',
|
||||||
|
// Existing dual-key preview state from older Coolify versions / mixed write paths.
|
||||||
|
'docker_compose_domains' => json_encode([
|
||||||
|
'web_api' => ['domain' => 'https://old-preview.example.com'],
|
||||||
|
'web-api' => ['domain' => ''],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preview->generate_preview_fqdn_compose();
|
||||||
|
$preview->refresh();
|
||||||
|
|
||||||
|
$domains = json_decode($preview->docker_compose_domains, true);
|
||||||
|
|
||||||
|
expect($domains)->toHaveKey('web-api')
|
||||||
|
->and($domains)->not->toHaveKey('web_api')
|
||||||
|
->and($domains)->toHaveCount(1)
|
||||||
|
->and($domains['web-api']['domain'])->toContain('web-api.example.com')
|
||||||
|
->and($domains['web-api']['domain'])->toContain('58')
|
||||||
|
->and($preview->fqdn)->toContain('web-api.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads legacy underscore application domains when generating previews for dashed services', function () {
|
||||||
|
$application = Application::factory()->create([
|
||||||
|
'build_pack' => 'dockercompose',
|
||||||
|
'docker_compose_domains' => json_encode([
|
||||||
|
'web_api' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preview = ApplicationPreview::create([
|
||||||
|
'application_id' => $application->id,
|
||||||
|
'pull_request_id' => 12,
|
||||||
|
'pull_request_html_url' => 'https://github.com/example/repo/pull/12',
|
||||||
|
'docker_compose_domains' => json_encode([
|
||||||
|
'web-api' => ['domain' => ''],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preview->generate_preview_fqdn_compose();
|
||||||
|
$preview->refresh();
|
||||||
|
|
||||||
|
$domains = json_decode($preview->docker_compose_domains, true);
|
||||||
|
|
||||||
|
expect($domains)->toHaveKey('web-api')
|
||||||
|
->and($domains)->not->toHaveKey('web_api')
|
||||||
|
->and($domains['web-api']['domain'])->toContain('legacy.example.com')
|
||||||
|
->and($preview->fqdn)->toContain('legacy.example.com');
|
||||||
|
});
|
||||||
|
|
|
||||||
93
tests/Feature/ServiceDomainPortCorruptionTest.php
Normal file
93
tests/Feature/ServiceDomainPortCorruptionTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature regression for #8798 / #8980: saving a service domain that includes a
|
||||||
|
* required port must not corrupt SERVICE_URL values.
|
||||||
|
*
|
||||||
|
* EditDomain saves fqdn then calls updateCompose() (Spatie-based) and parse().
|
||||||
|
* updateCompose is the path that writes SERVICE_URL_* from the saved FQDN and is
|
||||||
|
* fully exerciseable under SQLite; getFqdnWithoutPort coverage lives in unit tests.
|
||||||
|
*/
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceApplication;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\Team;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Spatie\Url\Url;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
InstanceSettings::forceCreate(['id' => 0]);
|
||||||
|
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||||
|
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
|
||||||
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateCompose keeps valid SERVICE_URL values when domain includes required port', function () {
|
||||||
|
$template = <<<'YAML'
|
||||||
|
services:
|
||||||
|
gitlab:
|
||||||
|
image: gitlab/gitlab-ce:latest
|
||||||
|
environment:
|
||||||
|
- SERVICE_URL_GITLAB_80
|
||||||
|
- EXTERNAL_URL=$SERVICE_URL_GITLAB
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'environment_id' => $this->environment->id,
|
||||||
|
'server_id' => $this->server->id,
|
||||||
|
'destination_id' => $this->destination->id,
|
||||||
|
'destination_type' => $this->destination->getMorphClass(),
|
||||||
|
'docker_compose_raw' => $template,
|
||||||
|
'compose_parsing_version' => '5',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$serviceApp = ServiceApplication::create([
|
||||||
|
'name' => 'gitlab',
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'image' => 'gitlab/gitlab-ce:latest',
|
||||||
|
'fqdn' => 'http://git.example.com:80',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// EditDomain::submit calls updateCompose after saving fqdn
|
||||||
|
updateCompose($serviceApp);
|
||||||
|
|
||||||
|
$serviceApp->refresh();
|
||||||
|
|
||||||
|
expect($serviceApp->fqdn)->toBe('http://git.example.com:80')
|
||||||
|
->and($serviceApp->fqdn)->not->toContain('//:80')
|
||||||
|
->and(fn () => Url::fromString($serviceApp->fqdn))->not->toThrow(Throwable::class);
|
||||||
|
|
||||||
|
$baseUrl = $service->environment_variables()->where('key', 'SERVICE_URL_GITLAB')->first();
|
||||||
|
$portUrl = $service->environment_variables()->where('key', 'SERVICE_URL_GITLAB_80')->first();
|
||||||
|
|
||||||
|
expect($baseUrl)->not->toBeNull()
|
||||||
|
->and($portUrl)->not->toBeNull()
|
||||||
|
->and($baseUrl->value)->toBe('http://git.example.com')
|
||||||
|
->and($portUrl->value)->toBe('http://git.example.com:80')
|
||||||
|
->and(fn () => Url::fromString($baseUrl->value))->not->toThrow(Throwable::class)
|
||||||
|
->and(fn () => Url::fromString($portUrl->value))->not->toThrow(Throwable::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFqdnWithoutPort used by serviceParser strip is safe for ported service domains', function () {
|
||||||
|
// Mirrors serviceParser when $savedService->fqdn already has a port
|
||||||
|
$savedFqdn = 'http://git.example.com:80';
|
||||||
|
$firstFqdn = trim((string) str($savedFqdn)->explode(',')->first());
|
||||||
|
$url = getFqdnWithoutPort($firstFqdn);
|
||||||
|
$port = '80';
|
||||||
|
$urlWithPort = "$url:$port";
|
||||||
|
|
||||||
|
expect($url)->toBe('http://git.example.com')
|
||||||
|
->and($urlWithPort)->toBe('http://git.example.com:80')
|
||||||
|
->and($urlWithPort)->not->toBe('http://git.example.com/:80')
|
||||||
|
->and(fn () => Url::fromString($urlWithPort))->not->toThrow(Throwable::class);
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Stringable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests to verify that the applicationParser function in parsers.php
|
* Unit tests to verify that the applicationParser function in parsers.php
|
||||||
* properly converts Stringable objects to plain strings to fix strict
|
* properly converts Stringable objects to plain strings to fix strict
|
||||||
|
|
@ -26,8 +28,8 @@
|
||||||
// Verify both are plain strings, not Stringable objects
|
// Verify both are plain strings, not Stringable objects
|
||||||
expect(is_string($originalServiceName))->toBeTrue('$originalServiceName should be a plain string');
|
expect(is_string($originalServiceName))->toBeTrue('$originalServiceName should be a plain string');
|
||||||
expect(is_string($serviceName))->toBeTrue('$serviceName 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($originalServiceName)->not->toBeInstanceOf(Stringable::class);
|
||||||
expect($serviceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
expect($serviceName)->not->toBeInstanceOf(Stringable::class);
|
||||||
|
|
||||||
// Verify the transformations work correctly
|
// Verify the transformations work correctly
|
||||||
expect($originalServiceName)->toBe('my-service');
|
expect($originalServiceName)->toBe('my-service');
|
||||||
|
|
@ -135,7 +137,7 @@
|
||||||
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||||
|
|
||||||
expect(is_string($originalServiceName))->toBeTrue();
|
expect(is_string($originalServiceName))->toBeTrue();
|
||||||
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
expect($originalServiceName)->not->toBeInstanceOf(Stringable::class);
|
||||||
expect($originalServiceName)->toBe('my-service');
|
expect($originalServiceName)->toBe('my-service');
|
||||||
|
|
||||||
// Verify it can be used in string interpolation (line 544)
|
// Verify it can be used in string interpolation (line 544)
|
||||||
|
|
@ -177,6 +179,9 @@
|
||||||
// Line 539: Check originalServiceName conversion
|
// Line 539: Check originalServiceName conversion
|
||||||
expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()");
|
expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()");
|
||||||
|
|
||||||
// Line 541: Check serviceName normalization
|
// Service name normalization for env keys / domain map (helper-based)
|
||||||
expect($parsersFile)->toContain("str(\$serviceName)->replace('-', '_')->replace('.', '_')->value()");
|
expect($parsersFile)->toContain('normalizeComposeServiceName((string) $serviceName)')
|
||||||
|
->and($parsersFile)->toContain('getComposeServiceDomainString')
|
||||||
|
->and($parsersFile)->toContain('putComposeServiceDomain')
|
||||||
|
->and($parsersFile)->toContain('findServiceApplicationForEnvName');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
224
tests/Unit/ComposeServiceNameHelpersTest.php
Normal file
224
tests/Unit/ComposeServiceNameHelpersTest.php
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Project\Application\General;
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
test('normalizeComposeServiceName replaces hyphens and dots', function () {
|
||||||
|
expect(normalizeComposeServiceName('another-service'))->toBe('another_service')
|
||||||
|
->and(normalizeComposeServiceName('api.test'))->toBe('api_test')
|
||||||
|
->and(normalizeComposeServiceName('api'))->toBe('api');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findComposeServiceName resolves legacy underscore keys to original names', function () {
|
||||||
|
$services = ['another-service', 'api.test', 'api'];
|
||||||
|
|
||||||
|
expect(findComposeServiceName('another-service', $services))->toBe('another-service')
|
||||||
|
->and(findComposeServiceName('another_service', $services))->toBe('another-service')
|
||||||
|
->and(findComposeServiceName('api.test', $services))->toBe('api.test')
|
||||||
|
->and(findComposeServiceName('api_test', $services))->toBe('api.test')
|
||||||
|
->and(findComposeServiceName('missing', $services))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getComposeServiceDomainString reads original and legacy keys without dotted data_get bugs', function () {
|
||||||
|
$domains = [
|
||||||
|
'api.test' => ['domain' => 'https://dotted.example.com'],
|
||||||
|
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getComposeServiceDomainString($domains, 'api.test'))->toBe('https://dotted.example.com')
|
||||||
|
->and(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com')
|
||||||
|
->and(getComposeServiceDomainString($domains, 'missing'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('putComposeServiceDomain writes original service keys and drops twin underscore keys', function () {
|
||||||
|
$domains = putComposeServiceDomain(
|
||||||
|
['another_service' => ['domain' => 'https://old.example.com']],
|
||||||
|
'another-service',
|
||||||
|
'https://new.example.com',
|
||||||
|
['another-service', 'analytics'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($domains)->toHaveKey('another-service')
|
||||||
|
->and($domains)->not->toHaveKey('another_service')
|
||||||
|
->and($domains['another-service']['domain'])->toBe('https://new.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rekeyComposeDomainsToServiceNames migrates underscore keys to original compose names', function () {
|
||||||
|
$rekeyed = rekeyComposeDomainsToServiceNames(
|
||||||
|
[
|
||||||
|
'another_service' => ['domain' => 'https://hyphen.example.com'],
|
||||||
|
'api_test' => ['domain' => 'https://dotted.example.com'],
|
||||||
|
'orphan' => ['domain' => 'https://orphan.example.com'],
|
||||||
|
],
|
||||||
|
['another-service', 'api.test'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($rekeyed)->toHaveKey('another-service')
|
||||||
|
->and($rekeyed)->not->toHaveKey('another_service')
|
||||||
|
->and($rekeyed['another-service']['domain'])->toBe('https://hyphen.example.com')
|
||||||
|
->and($rekeyed)->toHaveKey('api.test')
|
||||||
|
->and($rekeyed['api.test']['domain'])->toBe('https://dotted.example.com')
|
||||||
|
->and($rekeyed)->toHaveKey('orphan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rekeyComposeDomainsToServiceNames prefers the canonical domain regardless of twin key order', function (array $domains) {
|
||||||
|
$rekeyed = rekeyComposeDomainsToServiceNames($domains, ['another-service']);
|
||||||
|
|
||||||
|
expect($rekeyed['another-service']['domain'])->toBe('https://canonical.example.com');
|
||||||
|
})->with([
|
||||||
|
'legacy key first' => [[
|
||||||
|
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
'another-service' => ['domain' => 'https://canonical.example.com'],
|
||||||
|
]],
|
||||||
|
'canonical key first' => [[
|
||||||
|
'another-service' => ['domain' => 'https://canonical.example.com'],
|
||||||
|
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('rekeyComposeDomainsToServiceNames never lets empty canonical wipe filled legacy', function (array $domains) {
|
||||||
|
$rekeyed = rekeyComposeDomainsToServiceNames($domains, ['another-service']);
|
||||||
|
|
||||||
|
expect($rekeyed)->toHaveKey('another-service')
|
||||||
|
->and($rekeyed)->not->toHaveKey('another_service')
|
||||||
|
->and($rekeyed['another-service']['domain'])->toBe('https://legacy.example.com');
|
||||||
|
})->with([
|
||||||
|
'empty canonical first' => [[
|
||||||
|
'another-service' => ['domain' => ''],
|
||||||
|
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
]],
|
||||||
|
'filled legacy first' => [[
|
||||||
|
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
'another-service' => ['domain' => ''],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('getComposeServiceDomainString prefers filled twin over blank original key', function () {
|
||||||
|
$domains = [
|
||||||
|
'another-service' => ['domain' => ''],
|
||||||
|
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com')
|
||||||
|
->and(getComposeServiceDomainString($domains, 'another_service'))->toBe('https://legacy.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preferredComposeServiceNamesFromDomainKeys collapses underscore twins', function () {
|
||||||
|
expect(preferredComposeServiceNamesFromDomainKeys(['web_api', 'web-api', 'api']))->toEqualCanonicalizing(['web-api', 'api']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy underscore domain keys still resolve for hyphenated compose services', function () {
|
||||||
|
// Existing production shape: domains stored under underscore keys only.
|
||||||
|
$legacy = [
|
||||||
|
'another_service' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
'web' => ['domain' => 'https://web.example.com'],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getComposeServiceDomainString($legacy, 'another-service'))->toBe('https://legacy.example.com')
|
||||||
|
->and(getComposeServiceDomainString($legacy, 'another_service'))->toBe('https://legacy.example.com')
|
||||||
|
->and(getComposeServiceDomainString($legacy, 'web'))->toBe('https://web.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SERVICE env keys stay underscore-normalized for both storage shapes', function () {
|
||||||
|
// Mirrors ApplicationDeploymentJob: domain map key may be original or legacy.
|
||||||
|
foreach (['another-service', 'another_service'] as $storageKey) {
|
||||||
|
$envKey = str(normalizeComposeServiceName($storageKey))->upper()->toString();
|
||||||
|
expect($envKey)->toBe('ANOTHER_SERVICE')
|
||||||
|
->and('SERVICE_URL_'.$envKey)->toBe('SERVICE_URL_ANOTHER_SERVICE');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findComposeServiceName maps SERVICE env fragments used by serviceParser magic path', function () {
|
||||||
|
// serviceParser historically only did underscore→hyphen, so API_TEST became name "api-test"
|
||||||
|
// and missed compose service "api.test". normalize-based lookup finds the real name.
|
||||||
|
$services = ['another-service', 'api.test', 'web'];
|
||||||
|
|
||||||
|
expect(findComposeServiceName('another_service', $services))->toBe('another-service')
|
||||||
|
->and(findComposeServiceName('api_test', $services))->toBe('api.test')
|
||||||
|
->and(findComposeServiceName('api-test', $services))->toBe('api.test') // same normalized form
|
||||||
|
->and(findComposeServiceName('web', $services))->toBe('web');
|
||||||
|
|
||||||
|
// Exact DB lookup the old serviceParser used would miss dotted names:
|
||||||
|
expect(in_array('api-test', $services, true))->toBeFalse()
|
||||||
|
->and(in_array('api.test', $services, true))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalized service name collisions only resolve exact matches', function () {
|
||||||
|
$services = ['api-test', 'api.test'];
|
||||||
|
|
||||||
|
expect(findComposeServiceName('api-test', $services))->toBe('api-test')
|
||||||
|
->and(findComposeServiceName('api.test', $services))->toBe('api.test')
|
||||||
|
->and(findComposeServiceName('api_test', $services))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rekeyComposeDomainsToServiceNames keeps ambiguous normalized keys separate', function () {
|
||||||
|
$rekeyed = rekeyComposeDomainsToServiceNames(
|
||||||
|
[
|
||||||
|
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||||
|
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||||
|
'api_test' => ['domain' => 'https://legacy.example.com'],
|
||||||
|
],
|
||||||
|
['api-test', 'api.test'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($rekeyed['api-test']['domain'])->toBe('https://hyphen.example.com')
|
||||||
|
->and($rekeyed['api.test']['domain'])->toBe('https://dot.example.com')
|
||||||
|
->and($rekeyed['api_test']['domain'])->toBe('https://legacy.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty parsed compose services do not wipe existing domains', function () {
|
||||||
|
$application = new Application;
|
||||||
|
$application->docker_compose_domains = json_encode([
|
||||||
|
'web' => ['domain' => 'https://web.example.com'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$method = new ReflectionMethod($application, 'reconcileDockerComposeDomains');
|
||||||
|
$method->invoke($application, collect(['services' => []]));
|
||||||
|
|
||||||
|
expect($application->docker_compose_domains)->toBe(json_encode([
|
||||||
|
'web' => ['domain' => 'https://web.example.com'],
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalized service collisions retain domains under their exact compose keys', function () {
|
||||||
|
$domains = collect([
|
||||||
|
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||||
|
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rekeyed = rekeyComposeDomainsToServiceNames($domains, collect(['api-test', 'api.test']));
|
||||||
|
|
||||||
|
expect($rekeyed)->toBe([
|
||||||
|
'api-test' => ['domain' => 'https://hyphen.example.com'],
|
||||||
|
'api.test' => ['domain' => 'https://dot.example.com'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('General rekeys form domains to original compose service names', function (array|Collection $domains) {
|
||||||
|
$component = new General;
|
||||||
|
$component->parsedServices = collect([
|
||||||
|
'services' => collect([
|
||||||
|
'web-api' => [],
|
||||||
|
'metrics.internal' => [],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
$component->parsedServiceDomains = $domains;
|
||||||
|
|
||||||
|
$method = new ReflectionMethod($component, 'composeDomainsForStorage');
|
||||||
|
$stored = $method->invoke($component);
|
||||||
|
|
||||||
|
expect($stored)->toBe([
|
||||||
|
'web-api' => ['domain' => 'https://api.example.com'],
|
||||||
|
'metrics.internal' => ['domain' => 'https://metrics.example.com'],
|
||||||
|
]);
|
||||||
|
})->with([
|
||||||
|
'array' => [[
|
||||||
|
'web_api' => ['domain' => 'https://api.example.com'],
|
||||||
|
'metrics_internal' => ['domain' => 'https://metrics.example.com'],
|
||||||
|
]],
|
||||||
|
'collection' => [collect([
|
||||||
|
'web_api' => ['domain' => 'https://api.example.com'],
|
||||||
|
'metrics_internal' => ['domain' => 'https://metrics.example.com'],
|
||||||
|
])],
|
||||||
|
]);
|
||||||
93
tests/Unit/DomainUrlHelpersTest.php
Normal file
93
tests/Unit/DomainUrlHelpersTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared domain URL helpers used by application/service parsers and updateCompose.
|
||||||
|
*
|
||||||
|
* TDD targets:
|
||||||
|
* - getHostWithoutPort(): COOLIFY_FQDN / SERVICE_FQDN host-only form (no scheme, no port)
|
||||||
|
* - firstDomainFromList(): comma-separated FQDN lists (same as updateCompose / serviceParser)
|
||||||
|
* - getFqdnWithoutPort() remains the scheme+host form for COOLIFY_URL / SERVICE_URL
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getHostWithoutPort — expected behavior (fails until helper exists / is correct)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('getHostWithoutPort strips scheme and port from simple urls', function () {
|
||||||
|
expect(getHostWithoutPort('http://git.example.com:80'))->toBe('git.example.com')
|
||||||
|
->and(getHostWithoutPort('https://n8n.example.com:5678'))->toBe('n8n.example.com')
|
||||||
|
->and(getHostWithoutPort('http://git.example.com'))->toBe('git.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHostWithoutPort preserves path segments', function () {
|
||||||
|
expect(getHostWithoutPort('http://git.example.com:80/v1/realtime'))
|
||||||
|
->toBe('git.example.com/v1/realtime');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHostWithoutPort keeps real host when credentials are present', function () {
|
||||||
|
// Legacy COOLIFY_FQDN strip: replace scheme then before(':') → "user"
|
||||||
|
expect(getHostWithoutPort('http://user:secret@git.example.com:80'))
|
||||||
|
->toBe('git.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHostWithoutPort handles scheme-less host:port', function () {
|
||||||
|
expect(getHostWithoutPort('git.example.com:80'))->toBe('git.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHostWithoutPort returns original when host cannot be parsed', function () {
|
||||||
|
expect(getHostWithoutPort('not-a-url'))->toBe('not-a-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy COOLIFY_FQDN strip corrupts credential urls (documents why we refactor)', function () {
|
||||||
|
$fqdn = 'http://user:secret@git.example.com:80';
|
||||||
|
|
||||||
|
$legacy = (string) str($fqdn)
|
||||||
|
->replace('http://', '')
|
||||||
|
->replace('https://', '')
|
||||||
|
->before(':');
|
||||||
|
|
||||||
|
expect($legacy)->toBe('user')
|
||||||
|
->and($legacy)->not->toBe(getHostWithoutPort($fqdn));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// firstDomainFromList
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('firstDomainFromList returns the first comma-separated domain trimmed', function () {
|
||||||
|
expect(firstDomainFromList('http://a.example.com:80,http://b.example.com:80'))
|
||||||
|
->toBe('http://a.example.com:80')
|
||||||
|
->and(firstDomainFromList(' https://only.example.com '))
|
||||||
|
->toBe('https://only.example.com')
|
||||||
|
->and(firstDomainFromList(null))
|
||||||
|
->toBe('')
|
||||||
|
->and(firstDomainFromList(''))
|
||||||
|
->toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// updateCompose / parser pairing: URL + FQDN from the same helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('url and host helpers stay paired for ported service domains', function () {
|
||||||
|
$fqdn = 'http://git.example.com:80';
|
||||||
|
|
||||||
|
$urlValue = getFqdnWithoutPort($fqdn);
|
||||||
|
$fqdnValue = getHostWithoutPort($fqdn);
|
||||||
|
$port = '80';
|
||||||
|
|
||||||
|
expect($urlValue)->toBe('http://git.example.com')
|
||||||
|
->and($fqdnValue)->toBe('git.example.com')
|
||||||
|
->and($urlValue.':'.$port)->toBe('http://git.example.com:80')
|
||||||
|
->and($fqdnValue.':'.$port)->toBe('git.example.com:80')
|
||||||
|
->and($urlValue.':'.$port)->not->toContain('/:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first domain then helpers match multi-domain service FQDN handling', function () {
|
||||||
|
$saved = 'http://git.example.com:80,http://git-alt.example.com:80';
|
||||||
|
$first = firstDomainFromList($saved);
|
||||||
|
|
||||||
|
expect($first)->toBe('http://git.example.com:80')
|
||||||
|
->and(getFqdnWithoutPort($first))->toBe('http://git.example.com')
|
||||||
|
->and(getHostWithoutPort($first))->toBe('git.example.com');
|
||||||
|
});
|
||||||
51
tests/Unit/GetFqdnWithoutPortTest.php
Normal file
51
tests/Unit/GetFqdnWithoutPortTest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Spatie\Url\Url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for service domain FQDN port handling (#8798 / #8980).
|
||||||
|
*
|
||||||
|
* Fragile str()->after('://')->before(':') stripping corrupts URLs that contain
|
||||||
|
* credentials, IPv6, or other non-trivial structure. getFqdnWithoutPort() must
|
||||||
|
* strip only the port and keep a usable base URL for COOLIFY_URL / SERVICE_URL.
|
||||||
|
*/
|
||||||
|
test('getFqdnWithoutPort strips port from simple http urls without trailing slash', function () {
|
||||||
|
expect(getFqdnWithoutPort('http://git.example.com:80'))->toBe('http://git.example.com')
|
||||||
|
->and(getFqdnWithoutPort('https://n8n.example.com:5678'))->toBe('https://n8n.example.com')
|
||||||
|
->and(getFqdnWithoutPort('http://git.example.com'))->toBe('http://git.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFqdnWithoutPort preserves path segments when stripping port', function () {
|
||||||
|
expect(getFqdnWithoutPort('http://git.example.com:80/v1/realtime'))
|
||||||
|
->toBe('http://git.example.com/v1/realtime');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFqdnWithoutPort keeps host when credentials are present', function () {
|
||||||
|
// Fragile strip would turn this into "http://user" — helper must not.
|
||||||
|
expect(getFqdnWithoutPort('http://user:secret@git.example.com:80'))
|
||||||
|
->toBe('http://git.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getFqdnWithoutPort returns original string when input is not a valid url', function () {
|
||||||
|
expect(getFqdnWithoutPort('not-a-url'))->toBe('not-a-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fragile after/before strip corrupts credential urls (documents #8798 class of bugs)', function () {
|
||||||
|
$fqdn = 'http://user:secret@git.example.com:80';
|
||||||
|
|
||||||
|
$fragile = (string) str($fqdn)
|
||||||
|
->after('://')
|
||||||
|
->before(':')
|
||||||
|
->prepend(str($fqdn)->before('://')->append('://'));
|
||||||
|
|
||||||
|
expect($fragile)->toBe('http://user')
|
||||||
|
->and($fragile)->not->toBe(getFqdnWithoutPort($fqdn));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('port comparisons must cast so string template ports match int url ports', function () {
|
||||||
|
$envPort = Url::fromString('http://git.example.com:80')->getPort();
|
||||||
|
$predefinedPort = '80'; // yaml / data_get style
|
||||||
|
|
||||||
|
expect($envPort !== $predefinedPort)->toBeTrue('strict compare wrongly treats matching ports as different')
|
||||||
|
->and((int) $envPort !== (int) $predefinedPort)->toBeFalse();
|
||||||
|
});
|
||||||
97
tests/Unit/TraefikServiceNameSegmentTest.php
Normal file
97
tests/Unit/TraefikServiceNameSegmentTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
test('traefikServiceNameHash is stable and length 4', function () {
|
||||||
|
expect(traefikServiceNameHash('api.test'))->toBe(substr(md5('api.test'), 0, 4))
|
||||||
|
->and(traefikServiceNameHash('api.test'))->toHaveLength(4)
|
||||||
|
->and(traefikServiceNameHash('api.test'))->not->toBe(traefikServiceNameHash('api-test'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traefikSafeServiceNameSegment replaces dots and always appends hash', function () {
|
||||||
|
$segment = traefikSafeServiceNameSegment('api.test');
|
||||||
|
$hash = traefikServiceNameHash('api.test');
|
||||||
|
|
||||||
|
expect($segment)->toBe("api-test-{$hash}")
|
||||||
|
->and($segment)->not->toContain('.')
|
||||||
|
->and(str_ends_with($segment, "-{$hash}"))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traefikSafeServiceNameSegment keeps api.test and api-test distinct', function () {
|
||||||
|
$dot = traefikSafeServiceNameSegment('api.test');
|
||||||
|
$hyphen = traefikSafeServiceNameSegment('api-test');
|
||||||
|
|
||||||
|
expect($dot)->not->toBe($hyphen)
|
||||||
|
->and($dot)->toStartWith('api-test-')
|
||||||
|
->and($hyphen)->toStartWith('api-test-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traefikSafeServiceNameSegment normalizes underscores and odd characters', function () {
|
||||||
|
$segment = traefikSafeServiceNameSegment('web_api.v2');
|
||||||
|
$hash = traefikServiceNameHash('web_api.v2');
|
||||||
|
|
||||||
|
expect($segment)->toBe("web-api-v2-{$hash}")
|
||||||
|
->and($segment)->not->toContain('.')
|
||||||
|
->and($segment)->not->toContain('_');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fqdnLabelsForTraefik embeds safe hashed service segment without dotted router names', function () {
|
||||||
|
$uuid = 'testuuid1234';
|
||||||
|
$hashDot = traefikServiceNameHash('api.test');
|
||||||
|
$hashHyphen = traefikServiceNameHash('api-test');
|
||||||
|
|
||||||
|
$dotLabels = fqdnLabelsForTraefik(
|
||||||
|
uuid: $uuid,
|
||||||
|
domains: collect(['https://dot.example.com']),
|
||||||
|
is_force_https_enabled: true,
|
||||||
|
service_name: 'api.test',
|
||||||
|
image: 'nginx:alpine',
|
||||||
|
);
|
||||||
|
|
||||||
|
$hyphenLabels = fqdnLabelsForTraefik(
|
||||||
|
uuid: $uuid,
|
||||||
|
domains: collect(['https://hyphen.example.com']),
|
||||||
|
is_force_https_enabled: true,
|
||||||
|
service_name: 'api-test',
|
||||||
|
image: 'nginx:alpine',
|
||||||
|
);
|
||||||
|
|
||||||
|
$dotHttps = $dotLabels->first(
|
||||||
|
fn (string $line) => str_contains($line, "traefik.http.routers.https-0-{$uuid}-api-test-{$hashDot}.rule=")
|
||||||
|
);
|
||||||
|
$hyphenHttps = $hyphenLabels->first(
|
||||||
|
fn (string $line) => str_contains($line, "traefik.http.routers.https-0-{$uuid}-api-test-{$hashHyphen}.rule=")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dotHttps)->not->toBeNull()
|
||||||
|
->and($hyphenHttps)->not->toBeNull()
|
||||||
|
->and($dotHttps)->not->toContain('api.test')
|
||||||
|
->and($dotHttps)->not->toBe($hyphenHttps)
|
||||||
|
->and($hashDot)->not->toBe($hashHyphen);
|
||||||
|
|
||||||
|
$ruleLines = $dotLabels->merge($hyphenLabels)->filter(
|
||||||
|
fn (string $line) => str_contains($line, 'traefik.http.routers.') && str_contains($line, '.rule=')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Traefik label path must stay at traefik.http.routers.{name}.rule (5 segments).
|
||||||
|
foreach ($ruleLines as $line) {
|
||||||
|
$key = str($line)->before('=')->toString();
|
||||||
|
expect(explode('.', $key))->toHaveCount(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fqdnLabelsForTraefik hyphenated services also receive a hash suffix', function () {
|
||||||
|
$uuid = 'appuuid';
|
||||||
|
$hash = traefikServiceNameHash('another-service');
|
||||||
|
|
||||||
|
$labels = fqdnLabelsForTraefik(
|
||||||
|
uuid: $uuid,
|
||||||
|
domains: collect(['https://another.example.com']),
|
||||||
|
service_name: 'another-service',
|
||||||
|
image: 'nginx:alpine',
|
||||||
|
);
|
||||||
|
|
||||||
|
$routerLine = $labels->first(
|
||||||
|
fn (string $line) => str_contains($line, 'traefik.http.routers.https-0-') && str_contains($line, '.rule=')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($routerLine)->toContain("https-0-{$uuid}-another-service-{$hash}.rule=");
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue