fix(dns): replace unused Coolify records and show conflicts on automatic create

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-03 11:19:57 +01:00
co-authored by Claude Opus 5.5
parent 1da3124e10
commit c3a21eceeb
7 changed files with 84 additions and 9 deletions
+4
View File
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
### Fixed
- Handle an existing Cloudflare DNS record with a different IP when Coolify adds the DNS record of a new domain automatically. Coolify now replaces the record when it created the record and no other resource uses it. Otherwise it opens the **Configure DNS** dialog, where you can replace the record, and the domain shows the conflict instead of "Not checked"
## [v4.6.0] - 2026-11-02
### Breaking Changes
@@ -19,6 +19,8 @@ class DnsRecordConfigurationFinished implements ShouldBroadcastNow
public bool $successful,
public string $credential,
public string $message,
/** @var array{zone_id: int, record_id: string, current: string, proposed: string}|null The record that blocked the create. */
public ?array $conflict = null,
) {}
public function broadcastOn(): array
@@ -41,6 +43,7 @@ class DnsRecordConfigurationFinished implements ShouldBroadcastNow
'successful' => $this->successful,
'credential' => $this->credential,
'message' => $this->message,
'conflict' => $this->conflict,
])];
}
}
@@ -10,6 +10,7 @@ class DnsRecordConflictException extends RuntimeException
public readonly string $providerRecordId,
public readonly string $currentValue,
public readonly string $proposedValue,
public readonly ?string $currentComment = null,
) {
parent::__construct("DNS record already points to {$currentValue}.");
}
+54 -3
View File
@@ -3,8 +3,12 @@
namespace App\Jobs;
use App\Events\DnsRecordConfigurationFinished;
use App\Exceptions\DnsRecordConflictException;
use App\Models\DnsProviderZone;
use App\Models\ManagedDnsRecord;
use App\Models\ManagedDnsRecordReference;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\Dns\ManagedDnsRecordCleanup;
use App\Support\DnsRecordHints;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -34,6 +38,10 @@ class ConfigureDnsRecordJob implements ShouldQueue
public string $content,
) {}
/**
* A record with another value blocks the create. Coolify replaces it only when it is a record that Coolify created
* and that no other resource uses. Otherwise the conflict goes to the UI, where the user can replace it.
*/
public function handle(CloudflareDnsProvider $provider): void
{
$zone = DnsProviderZone::query()
@@ -48,8 +56,24 @@ class ConfigureDnsRecordJob implements ShouldQueue
return;
}
$resource = $this->resource();
try {
$provider->createRecord($zone, $this->hostname, $this->content, $this->resource());
try {
$provider->createRecord($zone, $this->hostname, $this->content, $resource);
$message = "DNS record added for {$this->hostname}.";
} catch (DnsRecordConflictException $conflict) {
if (! $this->isUnusedCoolifyRecord($zone, $conflict, $resource)) {
$this->finished($zone, false, $conflict->getMessage(), [
'zone_id' => $zone->id, 'record_id' => $conflict->providerRecordId,
'current' => $conflict->currentValue, 'proposed' => $conflict->proposedValue,
]);
return;
}
$provider->replaceRecord($zone, $conflict->providerRecordId, $this->hostname, $this->content, $resource, $conflict->currentValue);
$message = "DNS record replaced for {$this->hostname}. It pointed to {$conflict->currentValue}, which no other resource uses.";
}
} catch (LockTimeoutException) {
if ($this->attempts() < $this->tries) {
$this->release(10 * $this->attempts());
@@ -66,10 +90,36 @@ class ConfigureDnsRecordJob implements ShouldQueue
return;
}
$this->finished($zone, true, "DNS record added for {$this->hostname}.");
$this->finished($zone, true, $message);
}
private function finished(DnsProviderZone $zone, bool $successful, string $message): void
/**
* True when Coolify created the record (owned, and the provider comment still proves it) and no resource other
* than this one references the record or uses the hostname.
*/
private function isUnusedCoolifyRecord(DnsProviderZone $zone, DnsRecordConflictException $conflict, ?Model $resource): bool
{
if ($resource === null) {
return false;
}
$record = ManagedDnsRecord::query()->with('references.resource')
->where('dns_provider_zone_id', $zone->id)
->where('provider_record_id', $conflict->providerRecordId)
->first();
if ($record === null || ! $record->owned || $conflict->currentComment !== $record->ownershipComment()) {
return false;
}
$resourceKey = $resource->getMorphClass().'|'.$resource->getKey();
$usedByOtherResource = $record->references->contains(fn (ManagedDnsRecordReference $reference): bool => $reference->resource !== null
&& $reference->resource_type.'|'.$reference->resource_id !== $resourceKey);
return ! $usedByOtherResource && app(ManagedDnsRecordCleanup::class)->resourcesUsingHostname($this->hostname, [$resourceKey])->isEmpty();
}
/** @param array{zone_id: int, record_id: string, current: string, proposed: string}|null $conflict */
private function finished(DnsProviderZone $zone, bool $successful, string $message, ?array $conflict = null): void
{
DnsRecordConfigurationFinished::dispatch(
$this->teamId,
@@ -79,6 +129,7 @@ class ConfigureDnsRecordJob implements ShouldQueue
$successful,
$zone->integrationToken->name,
$message,
$conflict,
);
}
@@ -229,12 +229,17 @@ trait InteractsWithDnsProviders
}
protected function markDnsManaged(string $hostname, string $credential): void
{
$this->markDnsStatus($hostname, 'ok', "DNS record created through {$credential}.");
}
private function markDnsStatus(string $hostname, string $status, string $message): void
{
foreach ($this->domainRows as $index => $row) {
$rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
$this->domainRows[$index]['dns_status'] = 'ok';
$this->domainRows[$index]['dns_message'] = "DNS record created through {$credential}.";
$this->domainRows[$index]['dns_status'] = $status;
$this->domainRows[$index]['dns_message'] = $message;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
@@ -272,6 +277,17 @@ trait InteractsWithDnsProviders
return;
}
$conflict = $event['conflict'] ?? null;
if (is_array($conflict) && is_numeric($conflict['zone_id'] ?? null) && is_string($conflict['record_id'] ?? null)
&& is_string($conflict['current'] ?? null) && is_string($conflict['proposed'] ?? null)) {
$this->dnsProviderConflicts[strtolower($event['hostname']).'|'.(int) $conflict['zone_id']] = [
'record_id' => $conflict['record_id'], 'current' => $conflict['current'], 'proposed' => $conflict['proposed'],
];
$this->loadDnsProviderProposals();
$this->showDnsProviderModal = true;
}
$this->markDnsStatus($event['hostname'], 'failed', $event['message']);
$this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}");
}
+1 -1
View File
@@ -181,7 +181,7 @@ class CloudflareDnsProvider
throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare.");
}
if (count($remoteRecords) === 1) {
throw new DnsRecordConflictException($remoteRecords[0]['id'], $remoteRecords[0]['content'], $content);
throw new DnsRecordConflictException($remoteRecords[0]['id'], $remoteRecords[0]['content'], $content, $remoteRecords[0]['comment']);
}
$uuid = new_public_id();
@@ -25,9 +25,7 @@
<div class="min-w-0 text-sm"><div class="truncate font-medium text-black dark:text-fg">{{ $proposal['hostname'] }}</div>
<div class="text-xs text-neutral-500 dark:text-fg-dim">{{ $proposal['credential'] }} · {{ $proposal['zone'] }}@if (filled($proposal['target'])) · {{ $proposal['target'] }}@endif</div>
</div>
@if ($proposal['managed'])
<x-status-badge status="Managed by Coolify" type="success" />
@elseif (isset($dnsProviderConflicts[$key]))
@if (isset($dnsProviderConflicts[$key]))
@php($conflict = $dnsProviderConflicts[$key])
<div class="flex flex-col items-end gap-2 text-xs"><span class="text-red-600 dark:text-red-400">Currently {{ $conflict['current'] }}</span>
<x-modal-confirmation title="Replace conflicting DNS record?" isErrorButton buttonTitle="Replace record"
@@ -36,6 +34,8 @@
:confirmWithPassword="false" :confirmWithText="false" step2ButtonText="Replace record"
canGate="update" :canResource="$dnsAuthResource" />
</div>
@elseif ($proposal['managed'])
<x-status-badge status="Managed by Coolify" type="success" />
@elseif (blank($proposal['target']))
<span class="text-xs text-neutral-500 dark:text-fg-dim">Add manually</span>
@else