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

This commit is contained in:
Andras Bacsai
2026-08-03 23:08:56 +02:00
committed by GitHub
parent 7b18777f06
commit 0b843bb07c
18 changed files with 1443 additions and 220 deletions
+35 -2
View File
@@ -10,6 +10,37 @@ use Illuminate\Support\Str;
use Spatie\Url\Url;
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
{
$containers = collect([]);
@@ -507,8 +538,10 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$http_label = "http-{$loop}-{$uuid}";
$https_label = "https-{$loop}-{$uuid}";
if ($service_name) {
$http_label = "http-{$loop}-{$uuid}-{$service_name}";
$https_label = "https-{$loop}-{$uuid}-{$service_name}";
// Dots in service names split Traefik label paths; hash avoids api.test/api-test collisions.
$safeServiceName = traefikSafeServiceNameSegment($service_name);
$http_label = "http-{$loop}-{$uuid}-{$safeServiceName}";
$https_label = "https-{$loop}-{$uuid}-{$safeServiceName}";
}
if (str($image)->contains('ghost')) {
$labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)");
+279
View File
@@ -1,6 +1,7 @@
<?php
use App\Models\Application;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Support\Collection;
@@ -310,3 +311,281 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
'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;
}
+52 -68
View File
@@ -12,6 +12,7 @@ use App\Models\ServiceDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
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);
}
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($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.
if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
$parsed = parseServiceEnvironmentVariable($key->value());
$normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value();
$serviceExists = false;
foreach (array_keys($services) as $serviceNameKey) {
if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) {
$serviceExists = true;
break;
}
}
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($normalizedServiceName), 'domain');
$normalizedServiceName = normalizeComposeServiceName((string) $parsed['service_name']);
$originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services));
if ($originalServiceName !== null) {
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
$domainExists = getComposeServiceDomainString($domains, $originalServiceName);
if (is_null($domainExists)) {
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($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'])) {
$domainValue = "$domainValue:{$parsed['port']}";
}
$domains->put($normalizedServiceName, ['domain' => $domainValue]);
$resource->docker_compose_domains = $domains->toJson();
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
$domains,
$originalServiceName,
$domainValue,
array_keys($services),
));
$resource->save();
}
}
@@ -568,8 +567,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
$originalServiceName = str($serviceName)->replace('_', '-')->value();
// Always normalize service names to match docker_compose_domains lookup
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
// Env var SERVICE_* names still use underscores; domain map keys use original compose names.
$serviceName = normalizeComposeServiceName((string) $serviceName);
// Generate BOTH FQDN & URL
$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') {
// Check if a service with this name actually exists
$serviceExists = false;
foreach ($services as $serviceNameKey => $service) {
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
if ($transformedServiceName === $serviceName) {
$serviceExists = true;
break;
}
}
// Match env-derived name to the real compose service key (hyphens/dots preserved).
$composeServiceName = findComposeServiceName($serviceName, array_keys($services));
// Only add domain if the service exists
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($serviceName), 'domain');
if ($composeServiceName !== null) {
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
$domainExists = getComposeServiceDomainString($domains, $composeServiceName);
// Update domain using URL with port if applicable
$domainValue = $port ? $urlWithPort : $url;
if (is_null($domainExists)) {
$domains->put($serviceName, [
'domain' => $domainValue,
]);
$resource->docker_compose_domains = $domains->toJson();
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
$domains,
$composeServiceName,
$domainValue,
array_keys($services),
));
$resource->save();
}
}
@@ -1186,21 +1180,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($isPullRequest) {
$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 {
$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
if ($resource->build_pack !== 'dockercompose') {
$domains = collect([]);
}
$changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
$fqdns = data_get($domains, "$changedServiceName.domain");
// Prefer original compose service key; fall back to legacy underscore storage keys.
$fqdns = getComposeServiceDomainString($domains, (string) $serviceName);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
if ($resource->build_pack === 'dockercompose') {
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
$parsedDomain = composeDomainEntryString($domain);
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
if (filled($parsedDomain)) {
@@ -1209,12 +1203,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
$coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$coolifyEnvironments->put('SERVICE_URL_'.$serviceEnvKey, $coolifyUrl->__toString());
$coolifyEnvironments->put('SERVICE_FQDN_'.$serviceEnvKey, $coolifyFqdn);
$resource->environment_variables()->updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $resource->id,
'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
'key' => 'SERVICE_URL_'.$serviceEnvKey,
], [
'value' => $coolifyUrl->__toString(),
'is_preview' => false,
@@ -1222,7 +1217,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$resource->environment_variables()->updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $resource->id,
'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
'key' => 'SERVICE_FQDN_'.$serviceEnvKey,
], [
'value' => $coolifyFqdn,
'is_preview' => false,
@@ -1248,9 +1243,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$fqdns = str($fqdns)->explode(',');
if ($isPullRequest) {
$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) {
$found_fqdn = data_get($docker_compose_domains, "$changedServiceName.domain");
$found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName);
if ($found_fqdn) {
$fqdns = collect($found_fqdn);
} else {
@@ -1291,15 +1286,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$isDatabase = isDatabaseImage($image, $service);
// Add COOLIFY_FQDN & COOLIFY_URL to environment
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
});
$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(','));
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(','));
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(','));
}
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
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);
$url = generateUrl($server, "$fqdnFor-$uuid");
} elseif ($isServiceApplication) {
$fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
$url = 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).
$firstFqdn = firstDomainFromList($savedService->fqdn);
$fqdn = getFqdnWithoutPort($firstFqdn);
$url = $fqdn;
} else {
// For ServiceDatabase, generate fqdn/url without saving to the model
$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
$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();
if ($path !== '/') {
// 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().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->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)
$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)
$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.
$serviceExists->fqdn = $url;
$serviceExists->save();
@@ -1950,12 +1940,12 @@ function serviceParser(Service $resource): Collection
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->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)
$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)
$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->save();
}
@@ -2561,14 +2551,8 @@ function serviceParser(Service $resource): Collection
// Add COOLIFY_FQDN & COOLIFY_URL to environment
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
});
$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(','));
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(','));
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(','));
}
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
if ($environment->count() > 0) {
+10 -19
View File
@@ -139,7 +139,7 @@ function replaceVariables(string $variable): Stringable
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)
{
try {
if ($oneService->getMorphClass() === \App\Models\Application::class) {
if ($oneService->getMorphClass() === Application::class) {
$workdir = $oneService->workdir();
$server = $oneService->destination->server;
} else {
@@ -204,7 +204,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
instant_remote_process(["mkdir -p $fileLocation"], $server);
}
}
} catch (\Throwable $e) {
} catch (Throwable $e) {
return handleError($e);
}
}
@@ -214,7 +214,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$name = data_get($resource, 'name');
$dockerComposeRaw = data_get($resource, 'service.docker_compose_raw');
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);
@@ -325,22 +325,13 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
}
if ($resource->fqdn) {
$resourceFqdns = str($resource->fqdn)->explode(',');
$resourceFqdns = $resourceFqdns->first();
$url = Url::fromString($resourceFqdns);
$firstFqdn = firstDomainFromList($resource->fqdn);
$url = Url::fromString($firstFqdn);
$port = $url->getPort();
$path = $url->getPath();
// Prepare URL value (with scheme and host)
$urlValue = $url->getScheme().'://'.$url->getHost();
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
// Prepare FQDN value (host only, no scheme)
$fqdnHost = $url->getHost();
$fqdnValue = str($fqdnHost)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// Same helpers as application/service parsers (COOLIFY_URL / COOLIFY_FQDN).
$urlValue = getFqdnWithoutPort($firstFqdn);
$fqdnValue = getHostWithoutPort($firstFqdn);
// For each service name found in template, create BOTH SERVICE_URL and SERVICE_FQDN pairs
foreach ($serviceNamesToProcess as $serviceInfo) {
@@ -396,7 +387,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
}
}
}
} catch (\Throwable $e) {
} catch (Throwable $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('Failed to apply service application prerequisites', [
'service_id' => $service->id,
+65 -11
View File
@@ -749,18 +749,71 @@ function base_ip(): string
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 {
$url = Url::fromString($fqdn);
$host = $url->getHost();
$scheme = $url->getScheme();
$path = $url->getPath();
if ($host === '') {
return null;
}
return "$scheme://$host$path";
$path = $url->getPath();
if ($path === '' || $path === '/') {
$path = '';
}
return [
'scheme' => $url->getScheme(),
'host' => $host,
'path' => $path,
];
} 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 $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.
@@ -2538,7 +2591,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($env) {
$env_url = Url::fromString($savedService->fqdn);
$env_port = $env_url->getPort();
if ($env_port !== $predefinedPort) {
if ((int) $env_port !== (int) $predefinedPort) {
$env_url = $env_url->withPort($predefinedPort);
$savedService->fqdn = $env_url->__toString();
$savedService->save();
@@ -2623,7 +2676,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($env) {
$env_url = Url::fromString($env->value);
$env_port = $env_url->getPort();
if ($env_port !== $predefinedPort) {
if ((int) $env_port !== (int) $predefinedPort) {
$env_url = $env_url->withPort($predefinedPort);
$savedService->fqdn = $env_url->__toString();
$savedService->save();
@@ -3444,16 +3497,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($resource->serviceType()) {
$fqdns = generateServiceSpecificFqdns($resource);
} else {
$domains = collect(json_decode($resource->docker_compose_domains)) ?? [];
$domains = json_decode($resource->docker_compose_domains ?: '[]', true) ?: [];
if ($domains) {
$fqdns = data_get($domains, "$serviceName.domain");
// Dual-read: original compose name or legacy underscore key.
$fqdns = getComposeServiceDomainString($domains, (string) $serviceName);
if ($fqdns) {
$fqdns = str($fqdns)->explode(',');
if ($pull_request_id !== 0) {
$preview = $resource->previews()->find($preview_id);
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
if ($docker_compose_domains->count() > 0) {
$found_fqdn = data_get($docker_compose_domains, "$serviceName.domain");
$docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: [];
if (count($docker_compose_domains) > 0) {
$found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName);
if ($found_fqdn) {
$fqdns = collect($found_fqdn);
} else {