mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
1da3124e10
commit
c3a21eceeb
@@ -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}.");
|
||||
}
|
||||
|
||||
@@ -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']}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user