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

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

No files matched your search

+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;
}