fix(domains): detect Docker Compose domain conflicts
Normalize schemes when comparing routing identities and include per-service Compose domains in conflict checks. Show the conflicting service name and align the service domains heading layout.
This commit is contained in:
parent
3229141126
commit
3f1235158d
6 changed files with 210 additions and 66 deletions
|
|
@ -41,3 +41,10 @@ ## Use one DNS progress pattern
|
|||
- All DNS check entry points must set the domain badge to the same `checking` state.
|
||||
- Do not use separate loading feedback on Check all or per-domain action buttons when the badge is the progress indicator.
|
||||
- Verify the rendered badge uses the spinner slot instead of the default status dot.
|
||||
|
||||
## Confirm whether old reports still apply before changing code
|
||||
- For an old issue, first test the current branch and inspect later fixes. Do not assume that the historical reproduction still needs a new code change.
|
||||
|
||||
## Compare routing identity, not complete domain URLs
|
||||
- Domain-conflict checks must treat `http://host` and `https://host` as the same routing identity.
|
||||
- Reproduce reports with the exact stored schemes before stating that duplicate detection works.
|
||||
|
|
|
|||
|
|
@ -68,6 +68,52 @@ function isValidDomainUrl(string $url): bool
|
|||
return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false;
|
||||
}
|
||||
|
||||
function domainConflictKey(string $domain): string
|
||||
{
|
||||
$domain = str($domain)->endsWith('/')
|
||||
? str($domain)->beforeLast('/')->toString()
|
||||
: $domain;
|
||||
|
||||
return preg_replace('#^https?://#i', '', $domain) ?? $domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{domain: string, service_name: ?string}>
|
||||
*/
|
||||
function applicationDomainEntries(Application $application): Collection
|
||||
{
|
||||
$entries = collect(explode(',', (string) $application->fqdn))
|
||||
->filter(fn ($domain) => $domain !== '')
|
||||
->map(fn ($domain) => [
|
||||
'domain' => str($domain)->finish('/')->beforeLast('/')->toString(),
|
||||
'service_name' => null,
|
||||
]);
|
||||
|
||||
if ($application->build_pack !== 'dockercompose' || empty($application->docker_compose_domains)) {
|
||||
return $entries->values();
|
||||
}
|
||||
|
||||
$composeDomains = json_decode($application->docker_compose_domains, true);
|
||||
if (! is_array($composeDomains)) {
|
||||
return $entries->values();
|
||||
}
|
||||
|
||||
foreach ($composeDomains as $serviceName => $domainConfig) {
|
||||
foreach (explode(',', (string) data_get($domainConfig, 'domain')) as $domain) {
|
||||
if ($domain === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries->push([
|
||||
'domain' => str($domain)->finish('/')->beforeLast('/')->toString(),
|
||||
'service_name' => (string) $serviceName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $entries->values();
|
||||
}
|
||||
|
||||
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
|
||||
{
|
||||
$conflicts = [];
|
||||
|
|
@ -80,8 +126,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
|
|||
|
||||
if ($resource) {
|
||||
if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') {
|
||||
$domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
|
||||
$domains = collect($domains);
|
||||
$domains = applicationDomainEntries($resource)->pluck('domain');
|
||||
} else {
|
||||
$domains = collect($resource->fqdns);
|
||||
}
|
||||
|
|
@ -96,7 +141,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
|
|||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
|
||||
return str($domain);
|
||||
return domainConflictKey((string) $domain);
|
||||
});
|
||||
|
||||
// Filter applications by team if we have a current team
|
||||
|
|
@ -108,13 +153,9 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
|
|||
}
|
||||
$apps = $appsQuery->get();
|
||||
foreach ($apps as $app) {
|
||||
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
|
||||
foreach ($list_of_domains as $domain) {
|
||||
if (str($domain)->endsWith('/')) {
|
||||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
$naked_domain = str($domain)->value();
|
||||
if ($domains->contains($naked_domain)) {
|
||||
foreach (applicationDomainEntries($app) as $domainEntry) {
|
||||
$naked_domain = $domainEntry['domain'];
|
||||
if ($domains->contains(domainConflictKey($naked_domain))) {
|
||||
if (data_get($resource, 'uuid')) {
|
||||
if ($resource->uuid !== $app->uuid) {
|
||||
$conflicts[] = [
|
||||
|
|
@ -122,16 +163,18 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
|
|||
'resource_name' => $app->name,
|
||||
'resource_link' => $app->link(),
|
||||
'resource_type' => 'application',
|
||||
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
|
||||
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
|
||||
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
|
||||
];
|
||||
}
|
||||
} elseif ($domain) {
|
||||
} else {
|
||||
$conflicts[] = [
|
||||
'domain' => $naked_domain,
|
||||
'resource_name' => $app->name,
|
||||
'resource_link' => $app->link(),
|
||||
'resource_type' => 'application',
|
||||
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
|
||||
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
|
||||
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -153,7 +196,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
|
|||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
$naked_domain = str($domain)->value();
|
||||
if ($domains->contains($naked_domain)) {
|
||||
if ($domains->contains(domainConflictKey($naked_domain))) {
|
||||
if (data_get($resource, 'uuid')) {
|
||||
if ($resource->uuid !== $app->uuid) {
|
||||
$conflicts[] = [
|
||||
|
|
@ -185,7 +228,7 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null,
|
|||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
$naked_domain = str($domain)->value();
|
||||
if ($domains->contains($naked_domain)) {
|
||||
if ($domains->contains(domainConflictKey($naked_domain))) {
|
||||
$conflicts[] = [
|
||||
'domain' => $naked_domain,
|
||||
'resource_name' => 'Coolify Instance',
|
||||
|
|
@ -219,7 +262,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
|
|||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
|
||||
return str($domain);
|
||||
return domainConflictKey((string) $domain);
|
||||
});
|
||||
|
||||
$applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id', 'docker_compose_domains', 'build_pack']);
|
||||
|
|
@ -231,55 +274,21 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
|
|||
}
|
||||
|
||||
foreach ($applications as $app) {
|
||||
if (! is_null($app->fqdn)) {
|
||||
$list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
|
||||
foreach ($list_of_domains as $domain) {
|
||||
if (str($domain)->endsWith('/')) {
|
||||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
$naked_domain = str($domain)->value();
|
||||
if ($domains->contains($naked_domain)) {
|
||||
foreach (applicationDomainEntries($app) as $domainEntry) {
|
||||
$naked_domain = $domainEntry['domain'];
|
||||
if ($domains->contains(domainConflictKey($naked_domain))) {
|
||||
$conflicts[] = [
|
||||
'domain' => $naked_domain,
|
||||
'resource_name' => $app->name,
|
||||
'resource_uuid' => $app->uuid,
|
||||
'resource_type' => 'application',
|
||||
'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
|
||||
'message' => "Domain $naked_domain is already in use by application '{$app->name}'".($domainEntry['service_name'] ? " (service: {$domainEntry['service_name']})" : ''),
|
||||
...($domainEntry['service_name'] ? ['service_name' => $domainEntry['service_name']] : []),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($app->build_pack === 'dockercompose' && ! empty($app->docker_compose_domains)) {
|
||||
$dockerComposeDomains = json_decode($app->docker_compose_domains, true);
|
||||
if (is_array($dockerComposeDomains)) {
|
||||
foreach ($dockerComposeDomains as $serviceName => $domainConfig) {
|
||||
$domainValue = data_get($domainConfig, 'domain');
|
||||
if (empty($domainValue)) {
|
||||
continue;
|
||||
}
|
||||
$list_of_domains = collect(explode(',', $domainValue))->filter(fn ($fqdn) => $fqdn !== '');
|
||||
foreach ($list_of_domains as $domain) {
|
||||
if (str($domain)->endsWith('/')) {
|
||||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
$naked_domain = str($domain)->value();
|
||||
if ($domains->contains($naked_domain)) {
|
||||
$conflicts[] = [
|
||||
'domain' => $naked_domain,
|
||||
'resource_name' => $app->name,
|
||||
'resource_uuid' => $app->uuid,
|
||||
'resource_type' => 'application',
|
||||
'service_name' => $serviceName,
|
||||
'message' => "Domain $naked_domain is already in use by application '{$app->name}' (service: {$serviceName})",
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($serviceApplications as $app) {
|
||||
if (str($app->fqdn)->isEmpty()) {
|
||||
continue;
|
||||
|
|
@ -290,7 +299,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
|
|||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
$naked_domain = str($domain)->value();
|
||||
if ($domains->contains($naked_domain)) {
|
||||
if ($domains->contains(domainConflictKey($naked_domain))) {
|
||||
$conflicts[] = [
|
||||
'domain' => $naked_domain,
|
||||
'resource_name' => $app->service->name ?? 'Unknown Service',
|
||||
|
|
@ -310,7 +319,7 @@ function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $te
|
|||
$domain = str($domain)->beforeLast('/');
|
||||
}
|
||||
$naked_domain = str($domain)->value();
|
||||
if ($domains->contains($naked_domain)) {
|
||||
if ($domains->contains(domainConflictKey($naked_domain))) {
|
||||
$conflicts[] = [
|
||||
'domain' => $naked_domain,
|
||||
'resource_name' => 'Coolify Instance',
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class="underline hover:text-red-400">
|
|||
{{ $conflict['resource_name'] }}
|
||||
</a>
|
||||
@endif
|
||||
({{ $conflict['resource_type'] }})
|
||||
({{ $conflict['resource_type'] }}@if (filled($conflict['service_name'] ?? null)): {{ $conflict['service_name'] }}@endif)
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
|
|
|
|||
|
|
@ -69,9 +69,9 @@
|
|||
@endcannot
|
||||
|
||||
{{-- Toolbar --}}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3>Domains</h3>
|
||||
<h2 id="domains-section">Domains</h2>
|
||||
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} across {{ $domainGroups->count() }} service{{ $domainGroups->count() === 1 ? '' : 's' }}
|
||||
@if ($suggestedCount > 0)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use App\Models\User;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -1787,6 +1788,123 @@
|
|||
]);
|
||||
});
|
||||
|
||||
it('warns when adding a domain used by a docker compose application', function () {
|
||||
Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Compose Conflict App',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'fqdn' => null,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'https://compose-taken.example.com', 'redirect' => 'both'],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomain', 'https://compose-taken.example.com')
|
||||
->call('addDomain')
|
||||
->assertSet('showDomainConflictModal', true)
|
||||
->assertSet('domainConflicts.0.service_name', 'web')
|
||||
->assertSet('pendingAction', 'add');
|
||||
|
||||
expect($this->application->fresh()->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
it('warns when a compose domain uses a different scheme', function () {
|
||||
Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'HTTP Compose Conflict App',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'fqdn' => null,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => ['domain' => 'http://scheme-conflict.example.com', 'redirect' => 'both'],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomain', 'https://scheme-conflict.example.com')
|
||||
->call('addDomain')
|
||||
->assertSet('showDomainConflictModal', true)
|
||||
->assertSet('domainConflicts.0.service_name', 'web');
|
||||
|
||||
expect($this->application->fresh()->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
it('shows the compose service name in the domain conflict modal', function () {
|
||||
$html = Blade::render(
|
||||
'<x-domain-conflict-modal :conflicts="$conflicts" :show-modal="true" />',
|
||||
['conflicts' => [[
|
||||
'domain' => 'https://compose-taken.example.com',
|
||||
'resource_name' => 'Compose Conflict App',
|
||||
'resource_link' => '#',
|
||||
'resource_type' => 'application',
|
||||
'service_name' => 'web',
|
||||
]]],
|
||||
);
|
||||
|
||||
expect($html)->toContain('(application: web)');
|
||||
});
|
||||
|
||||
it('checks each domain configured for one docker compose service', function () {
|
||||
Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Conflicting App',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'fqdn' => 'https://second-compose.example.com',
|
||||
'build_pack' => 'nixpacks',
|
||||
]);
|
||||
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'web' => [
|
||||
'domain' => 'https://first-compose.example.com,https://second-compose.example.com',
|
||||
'redirect' => 'both',
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$result = checkDomainUsage(resource: $this->application->fresh());
|
||||
|
||||
expect($result['hasConflicts'])->toBeTrue()
|
||||
->and($result['conflicts'])->toHaveCount(1)
|
||||
->and($result['conflicts'][0]['domain'])->toBe('https://second-compose.example.com');
|
||||
});
|
||||
|
||||
it('uses docker compose domains in API conflict checks', function () {
|
||||
$composeApplication = Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'Compose API Conflict App',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'fqdn' => null,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_domains' => json_encode([
|
||||
'api' => ['domain' => 'https://compose-api.example.com', 'redirect' => 'both'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI(
|
||||
['https://compose-api.example.com'],
|
||||
(string) $this->team->id,
|
||||
$this->application->uuid,
|
||||
);
|
||||
|
||||
expect($result['hasConflicts'])->toBeTrue()
|
||||
->and($result['conflicts'])->toHaveCount(1)
|
||||
->and($result['conflicts'][0]['resource_uuid'])->toBe($composeApplication->uuid)
|
||||
->and($result['conflicts'][0]['service_name'])->toBe('api');
|
||||
});
|
||||
|
||||
it('saves after confirming a domain conflict on edit', function () {
|
||||
Application::factory()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
|
|
|
|||
|
|
@ -225,6 +225,16 @@
|
|||
->not->toContain('placeholder="https://app.example.com"');
|
||||
});
|
||||
|
||||
it('matches the application domains toolbar heading and top spacing', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div class="flex flex-wrap items-center gap-2">')
|
||||
->toContain('<h2 id="domains-section">Domains</h2>')
|
||||
->not->toContain('<div class="mt-2 flex flex-wrap items-center gap-2">')
|
||||
->not->toContain('<h3>Domains</h3>');
|
||||
});
|
||||
|
||||
it('resets the add domain dns gate when segmented domain fields change', function () {
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set('addDomainDnsFailed', true)
|
||||
|
|
|
|||
Loading…
Reference in a new issue