mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Closes #12436 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
682 lines
35 KiB
PHP
682 lines
35 KiB
PHP
<?php
|
|
|
|
use App\Enums\ManagedDnsDeletionResult;
|
|
use App\Jobs\CheckDomainDnsJob;
|
|
use App\Jobs\ConfigureDnsRecordJob;
|
|
use App\Jobs\ReleaseManagedDnsRecordsJob;
|
|
use App\Jobs\ReleaseRemovedDnsHostnamesJob;
|
|
use App\Livewire\Project\Application\Domains;
|
|
use App\Livewire\Project\Application\General;
|
|
use App\Livewire\Project\Application\PreviewDomains;
|
|
use App\Livewire\Project\Service\Domains as ServiceDomains;
|
|
use App\Livewire\Project\Service\Index as ServiceIndex;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationPreview;
|
|
use App\Models\DnsProviderZone;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\IntegrationToken;
|
|
use App\Models\ManagedDnsRecord;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use App\Models\ServiceApplication;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\Dns\CloudflareDnsProvider;
|
|
use App\Services\Dns\ManagedDnsHostnameLock;
|
|
use App\Services\Dns\ManagedDnsRecordCleanup;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->withoutVite();
|
|
// A new hostname of an automatic DNS zone also queues a record; DnsProviderManagementTest and DnsApiTest cover that.
|
|
Queue::fake([CheckDomainDnsJob::class, ConfigureDnsRecordJob::class]);
|
|
config()->set('app.maintenance.store', 'array');
|
|
config()->set('app.id', 'test-instance');
|
|
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
|
['id' => 0],
|
|
['id' => 0, 'is_dns_validation_enabled' => false, 'is_api_enabled' => true],
|
|
));
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
|
$this->actingAs($this->user);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'ip' => '203.0.113.10']);
|
|
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
|
$this->destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
|
|
['server_id' => $this->server->id, 'network' => 'coolify'],
|
|
['uuid' => (string) Str::uuid(), 'name' => 'test-docker'],
|
|
));
|
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
|
|
|
$this->token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
|
|
$this->zone = DnsProviderZone::factory()->for($this->token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
|
|
});
|
|
|
|
/**
|
|
* A hostname lock that runs a callback right before the next lock is taken, to simulate a concurrent change
|
|
* that happens between the release query and the lock.
|
|
*/
|
|
class InterleavingManagedDnsHostnameLock extends ManagedDnsHostnameLock
|
|
{
|
|
public ?Closure $beforeNextRun = null;
|
|
|
|
public function run(int $teamId, string $hostname, callable $callback): mixed
|
|
{
|
|
if ($this->beforeNextRun !== null) {
|
|
$interleaved = $this->beforeNextRun;
|
|
$this->beforeNextRun = null;
|
|
$interleaved();
|
|
}
|
|
|
|
return parent::run($teamId, $hostname, $callback);
|
|
}
|
|
}
|
|
|
|
function editReleaseApiToken(User $user, Team $team, array $abilities = ['*']): string
|
|
{
|
|
$plainTextToken = Str::random(40);
|
|
$token = $user->tokens()->create([
|
|
'name' => 'dns-edit-release-'.Str::random(6),
|
|
'token' => hash('sha256', $plainTextToken),
|
|
'abilities' => $abilities,
|
|
'team_id' => $team->id,
|
|
]);
|
|
auth()->logout();
|
|
|
|
return $token->getKey().'|'.$plainTextToken;
|
|
}
|
|
|
|
function editReleaseService(object $test, string $fqdn): ServiceApplication
|
|
{
|
|
$service = Service::factory()->create([
|
|
'server_id' => $test->server->id,
|
|
'destination_id' => $test->destination->id,
|
|
'destination_type' => $test->destination->getMorphClass(),
|
|
'environment_id' => $test->environment->id,
|
|
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
|
]);
|
|
|
|
return ServiceApplication::create([
|
|
'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'human_name' => 'Web',
|
|
'image' => 'nginx:alpine', 'fqdn' => $fqdn,
|
|
]);
|
|
}
|
|
|
|
function editApplicationDomain(Application $application, string $newHost): void
|
|
{
|
|
Livewire::test(Domains::class, ['application' => $application->fresh()])
|
|
->call('startEdit', 0)
|
|
->set('editingDomainParts.scheme', 'https')
|
|
->set('editingDomainParts.host', $newHost)
|
|
->call('updateDomain')
|
|
->assertHasNoErrors();
|
|
}
|
|
|
|
describe('UI application domain edit', function () {
|
|
test('an edited hostname releases and deletes its owned record', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
|
|
Livewire::test(Domains::class, ['application' => $application])
|
|
->call('startEdit', 0)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertDispatched('success', 'Domain updated.')
|
|
->assertNotDispatched('warning');
|
|
|
|
expect($application->fresh()->fqdn)->toBe('https://new.example.com')
|
|
->and(sentDnsRequests('DELETE'))->toBe(1)
|
|
->and($cloudflare['records'])->toBeEmpty()
|
|
->and($record->fresh())->toBeNull();
|
|
});
|
|
|
|
test('an edit that keeps the hostname keeps the record', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
|
|
Livewire::test(Domains::class, ['application' => $application])
|
|
->call('startEdit', 0)
|
|
->set('editingDomainParts.scheme', 'http')
|
|
->set('editingDomainParts.path', '/api')
|
|
->call('updateDomain')
|
|
->assertDispatched('success', 'Domain updated.');
|
|
|
|
expect($application->fresh()->fqdn)->toBe('http://app.example.com/api')
|
|
->and(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->references()->pluck('resource_id')->all())->toBe([$application->id]);
|
|
});
|
|
|
|
test('a hostname still used by another application of the team keeps the record', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$other = createDnsTestApplication($this, 'https://app.example.com/other');
|
|
|
|
editApplicationDomain($application, 'new.example.com');
|
|
|
|
expect(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->references()->pluck('resource_id')->all())->toBe([$other->id]);
|
|
});
|
|
|
|
test('records of another team are never touched', function () {
|
|
$cloudflare = fakeCloudflareDns([[
|
|
'id' => 'record-foreign', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => null,
|
|
]]);
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$otherToken = IntegrationToken::factory()->for(Team::factory()->create())->create(['provider' => 'cloudflare', 'token' => 'other']);
|
|
$otherZone = DnsProviderZone::factory()->for($otherToken)->create(['provider_zone_id' => 'zone-2', 'name' => 'example.com']);
|
|
$foreignRecord = ManagedDnsRecord::factory()->owned()->create([
|
|
'dns_provider_zone_id' => $otherZone->id, 'provider_record_id' => 'record-foreign',
|
|
'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
|
|
]);
|
|
$foreignRecord->addReference($application);
|
|
|
|
editApplicationDomain($application, 'new.example.com');
|
|
|
|
expect(Http::recorded())->toBeEmpty()
|
|
->and($cloudflare['records'])->toHaveKey('record-foreign')
|
|
->and($foreignRecord->fresh())->not->toBeNull()
|
|
->and($foreignRecord->references()->count())->toBe(1);
|
|
});
|
|
|
|
test('a member without update permission cannot release a record by editing', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
|
$this->actingAs($this->user->fresh());
|
|
|
|
Livewire::test(Domains::class, ['application' => $application->fresh()])
|
|
->set('editingIndex', 0)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertNotDispatched('success');
|
|
|
|
expect($application->fresh()->fqdn)->toBe('https://app.example.com')
|
|
->and(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->fresh())->not->toBeNull();
|
|
});
|
|
|
|
test('an adopted record is forgotten but never deleted', function () {
|
|
$cloudflare = fakeCloudflareDns([[
|
|
'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => 'hand made',
|
|
]]);
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
|
|
Livewire::test(Domains::class, ['application' => $application])
|
|
->call('startEdit', 0)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertDispatched('success', 'Domain updated.')
|
|
->assertNotDispatched('warning');
|
|
|
|
expect($record->owned)->toBeFalse()
|
|
->and(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveKey('record-1')
|
|
->and($record->fresh())->toBeNull();
|
|
});
|
|
|
|
test('a record changed outside Coolify stays and a warning is shown', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$records = $cloudflare['records'];
|
|
$records[$record->provider_record_id]['comment'] = 'production record';
|
|
$cloudflare['records'] = $records;
|
|
|
|
Livewire::test(Domains::class, ['application' => $application])
|
|
->call('startEdit', 0)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertDispatched('warning', 'The domain was updated, but its DNS record changed externally and was left untouched.');
|
|
|
|
expect(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveKey($record->provider_record_id)
|
|
->and($record->fresh())->toBeNull();
|
|
});
|
|
|
|
test('an unreachable provider keeps the record and shows a warning', function () {
|
|
fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
Http::fake(fn () => throw new ConnectionException('Cloudflare is unreachable.'));
|
|
|
|
Livewire::test(Domains::class, ['application' => $application])
|
|
->call('startEdit', 0)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertDispatched('warning', 'The domain was updated, but its DNS record could not be deleted because Cloudflare did not respond. It was left untouched.');
|
|
|
|
expect($application->fresh()->fqdn)->toBe('https://new.example.com')
|
|
->and($record->fresh())->not->toBeNull()
|
|
->and($record->references()->count())->toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('UI service domain edit', function () {
|
|
test('an edited hostname releases and deletes its owned record', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$serviceApplication = editReleaseService($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $serviceApplication);
|
|
|
|
Livewire::test(ServiceDomains::class, ['service' => $serviceApplication->service->fresh(['applications', 'server'])])
|
|
->call('startEdit', 0)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertDispatched('success', 'Domain updated.')
|
|
->assertNotDispatched('warning');
|
|
|
|
expect($serviceApplication->fresh()->fqdn)->toBe('https://new.example.com')
|
|
->and(sentDnsRequests('DELETE'))->toBe(1)
|
|
->and($cloudflare['records'])->toBeEmpty()
|
|
->and($record->fresh())->toBeNull();
|
|
});
|
|
|
|
test('a member without update permission cannot release a record by editing', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$serviceApplication = editReleaseService($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $serviceApplication);
|
|
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
|
$this->actingAs($this->user->fresh());
|
|
|
|
Livewire::test(ServiceDomains::class, ['service' => $serviceApplication->service->fresh(['applications', 'server'])])
|
|
->set('editingIndex', 0)
|
|
->set('editingServiceApplicationId', $serviceApplication->id)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertNotDispatched('success');
|
|
|
|
expect($serviceApplication->fresh()->fqdn)->toBe('https://app.example.com')
|
|
->and(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->fresh())->not->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('API domain edit', function () {
|
|
test('an application domain update releases the removed hostname', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com,https://keep.example.com');
|
|
$provider = app(CloudflareDnsProvider::class);
|
|
$removed = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$kept = $provider->createRecord($this->zone, 'keep.example.com', '203.0.113.10', $application);
|
|
|
|
$this->withToken(editReleaseApiToken($this->user, $this->team))
|
|
->patchJson("/api/v1/applications/{$application->uuid}", ['domains' => 'https://keep.example.com,https://new.example.com'])
|
|
->assertOk();
|
|
|
|
expect($application->fresh()->fqdn)->toBe('https://keep.example.com,https://new.example.com')
|
|
->and(sentDnsRequests('DELETE'))->toBe(1)
|
|
->and($removed->fresh())->toBeNull()
|
|
->and($kept->fresh())->not->toBeNull()
|
|
->and($cloudflare['records'])->toHaveCount(1);
|
|
});
|
|
|
|
test('an application docker compose domain update releases the removed hostname', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://unused.example.org');
|
|
$application->update([
|
|
'build_pack' => 'dockercompose',
|
|
'fqdn' => null,
|
|
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
|
|
'docker_compose_domains' => json_encode([
|
|
'web' => ['domain' => 'https://web.example.com'],
|
|
'api' => ['domain' => 'https://api.example.com'],
|
|
]),
|
|
]);
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'api.example.com', '203.0.113.10', $application);
|
|
|
|
$this->withToken(editReleaseApiToken($this->user, $this->team))
|
|
->patchJson("/api/v1/applications/{$application->uuid}", ['docker_compose_domains' => [
|
|
['name' => 'web', 'domain' => 'https://web.example.com'],
|
|
['name' => 'api', 'domain' => 'https://api2.example.com'],
|
|
]])
|
|
->assertOk();
|
|
|
|
expect(sentDnsRequests('DELETE'))->toBe(1)
|
|
->and($cloudflare['records'])->toBeEmpty()
|
|
->and($record->fresh())->toBeNull();
|
|
});
|
|
|
|
test('a service domain update releases the removed hostname', function (string $endpoint) {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$serviceApplication = editReleaseService($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $serviceApplication);
|
|
$service = $serviceApplication->service;
|
|
[$path, $payload] = $endpoint === 'service'
|
|
? ["/api/v1/services/{$service->uuid}", ['urls' => [['name' => 'web', 'url' => 'https://new.example.com']]]]
|
|
: ["/api/v1/services/{$service->uuid}/applications/{$serviceApplication->uuid}", ['url' => 'https://new.example.com']];
|
|
|
|
$this->withToken(editReleaseApiToken($this->user, $this->team))->patchJson($path, $payload)->assertOk();
|
|
|
|
expect($serviceApplication->fresh()->fqdn)->toBe('https://new.example.com')
|
|
->and(sentDnsRequests('DELETE'))->toBe(1)
|
|
->and($cloudflare['records'])->toBeEmpty()
|
|
->and($record->fresh())->toBeNull();
|
|
})->with(['service', 'service application']);
|
|
|
|
test('a preview domain update releases the removed hostname', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://main.example.org');
|
|
$preview = ApplicationPreview::query()->create([
|
|
'application_id' => $application->id, 'pull_request_id' => 5, 'pull_request_html_url' => 'https://github.com/x/y/pull/5',
|
|
'fqdn' => 'https://pr-5.example.com',
|
|
]);
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'pr-5.example.com', '203.0.113.10', $preview);
|
|
|
|
$this->withToken(editReleaseApiToken($this->user, $this->team))
|
|
->patchJson("/api/v1/applications/{$application->uuid}/previews/5", ['domains' => 'https://pr-5-new.example.com'])
|
|
->assertOk();
|
|
|
|
expect(sentDnsRequests('DELETE'))->toBe(1)
|
|
->and($cloudflare['records'])->toBeEmpty()
|
|
->and($record->fresh())->toBeNull();
|
|
});
|
|
|
|
test('a member token cannot release a record by editing', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
|
|
|
$this->withToken(editReleaseApiToken($this->user->fresh(), $this->team))
|
|
->patchJson("/api/v1/applications/{$application->uuid}", ['domains' => 'https://new.example.com'])
|
|
->assertForbidden();
|
|
|
|
expect($application->fresh()->fqdn)->toBe('https://app.example.com')
|
|
->and(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->fresh())->not->toBeNull();
|
|
});
|
|
|
|
test('a token of another team cannot release a record by editing', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$otherTeam = Team::factory()->create();
|
|
$otherUser = User::factory()->create();
|
|
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
|
|
|
$this->withToken(editReleaseApiToken($otherUser, $otherTeam))
|
|
->patchJson("/api/v1/applications/{$application->uuid}", ['domains' => 'https://new.example.com'])
|
|
->assertNotFound();
|
|
|
|
expect(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->fresh())->not->toBeNull();
|
|
});
|
|
|
|
test('nothing is queued when the resource references no managed record', function () {
|
|
Queue::fake([ReleaseRemovedDnsHostnamesJob::class]);
|
|
fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
|
|
$this->withToken(editReleaseApiToken($this->user, $this->team))
|
|
->patchJson("/api/v1/applications/{$application->uuid}", ['domains' => 'https://new.example.com'])
|
|
->assertOk();
|
|
|
|
Queue::assertNotPushed(ReleaseRemovedDnsHostnamesJob::class);
|
|
});
|
|
|
|
test('the queued release retries while cloudflare is unreachable', function () {
|
|
fakeCloudflareDns();
|
|
$application = createDnsTestApplication($this, 'https://new.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
Http::fake(fn () => throw new ConnectionException('Cloudflare is unreachable.'));
|
|
|
|
$job = (new ReleaseRemovedDnsHostnamesJob($application->getMorphClass(), $application->id, $this->team->id, ['app.example.com']))
|
|
->withFakeQueueInteractions();
|
|
$job->handle(app(ManagedDnsRecordCleanup::class));
|
|
|
|
$job->assertReleased(60);
|
|
expect($record->fresh())->not->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('hostname lock', function () {
|
|
test('a release while the hostname lock is held keeps the record and warns', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
app()->instance(ManagedDnsHostnameLock::class, new ManagedDnsHostnameLock(waitSeconds: 0));
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$heldLock = Cache::lock(ManagedDnsHostnameLock::key($this->team->id, 'app.example.com'), 60);
|
|
expect($heldLock->get())->toBeTrue();
|
|
|
|
Livewire::test(Domains::class, ['application' => $application])
|
|
->call('startEdit', 0)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertDispatched('warning', 'The domain was updated, but another DNS change for the same hostname was in progress. Its DNS record was left untouched.');
|
|
|
|
expect(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->fresh())->not->toBeNull()
|
|
->and($record->references()->count())->toBe(1);
|
|
$heldLock->release();
|
|
});
|
|
|
|
test('the release job of a deleted resource retries while the hostname lock is held', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
app()->instance(ManagedDnsHostnameLock::class, new ManagedDnsHostnameLock(waitSeconds: 0));
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
Application::withoutEvents(fn () => $application->delete());
|
|
$heldLock = Cache::lock(ManagedDnsHostnameLock::key($this->team->id, 'app.example.com'), 60);
|
|
$heldLock->get();
|
|
|
|
$job = (new ReleaseManagedDnsRecordsJob($application->getMorphClass(), $application->id))->withFakeQueueInteractions();
|
|
$job->handle(app(ManagedDnsRecordCleanup::class));
|
|
|
|
$job->assertReleased(60);
|
|
expect(sentDnsRequests('DELETE'))->toBe(0)->and($record->fresh())->not->toBeNull();
|
|
|
|
$heldLock->release();
|
|
$retry = (new ReleaseManagedDnsRecordsJob($application->getMorphClass(), $application->id))->withFakeQueueInteractions();
|
|
$retry->handle(app(ManagedDnsRecordCleanup::class));
|
|
|
|
$retry->assertNotReleased();
|
|
expect(sentDnsRequests('DELETE'))->toBe(1)
|
|
->and($cloudflare['records'])->toBeEmpty()
|
|
->and($record->fresh())->toBeNull();
|
|
});
|
|
|
|
test('a create while a release holds the lock is retried and then creates the record', function () {
|
|
fakeCloudflareDns();
|
|
app()->instance(ManagedDnsHostnameLock::class, new ManagedDnsHostnameLock(waitSeconds: 0));
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$heldLock = Cache::lock(ManagedDnsHostnameLock::key($this->team->id, 'app.example.com'), 60);
|
|
$heldLock->get();
|
|
|
|
$job = (new ConfigureDnsRecordJob($this->team->id, $this->zone->id, $application->getMorphClass(), $application->id, 'app.example.com', '203.0.113.10'))
|
|
->withFakeQueueInteractions();
|
|
$job->handle(app(CloudflareDnsProvider::class));
|
|
|
|
$job->assertReleased(10);
|
|
expect(sentDnsRequests('POST'))->toBe(0)->and(ManagedDnsRecord::query()->count())->toBe(0);
|
|
|
|
$heldLock->release();
|
|
$retry = (new ConfigureDnsRecordJob($this->team->id, $this->zone->id, $application->getMorphClass(), $application->id, 'app.example.com', '203.0.113.10'))
|
|
->withFakeQueueInteractions();
|
|
$retry->handle(app(CloudflareDnsProvider::class));
|
|
|
|
$retry->assertNotReleased();
|
|
$record = ManagedDnsRecord::query()->sole();
|
|
expect(sentDnsRequests('POST'))->toBe(1)
|
|
->and($record->owned)->toBeTrue()
|
|
->and($record->references()->pluck('resource_id')->all())->toBe([$application->id]);
|
|
});
|
|
|
|
test('a create from the UI while the lock is held shows a clear message', function () {
|
|
fakeCloudflareDns();
|
|
app()->instance(ManagedDnsHostnameLock::class, new ManagedDnsHostnameLock(waitSeconds: 0));
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
$heldLock = Cache::lock(ManagedDnsHostnameLock::key($this->team->id, 'app.example.com'), 60);
|
|
$heldLock->get();
|
|
|
|
Livewire::test(Domains::class, ['application' => $application])
|
|
->call('createManagedDnsRecord', 'app.example.com', $this->zone->id)
|
|
->assertDispatched('error', 'Another DNS change for app.example.com is in progress. Try again in a few seconds.');
|
|
|
|
expect(sentDnsRequests('POST'))->toBe(0)->and(ManagedDnsRecord::query()->count())->toBe(0);
|
|
$heldLock->release();
|
|
});
|
|
|
|
test('a create that waited for a release creates the deleted record again', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$lock = new InterleavingManagedDnsHostnameLock;
|
|
app()->instance(ManagedDnsHostnameLock::class, $lock);
|
|
$oldApplication = createDnsTestApplication($this, 'https://app.example.com');
|
|
$oldRecord = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $oldApplication);
|
|
$oldApplication->update(['fqdn' => 'https://moved.example.com']);
|
|
$newApplication = createDnsTestApplication($this, 'https://unrelated.example.org');
|
|
|
|
// The release holds the lock first; the new application adds the hostname while the create waits for it.
|
|
$lock->beforeNextRun = function () use ($oldApplication, $newApplication): void {
|
|
app(ManagedDnsRecordCleanup::class)->releaseHostname($oldApplication, 'app.example.com', $this->team->id);
|
|
$newApplication->update(['fqdn' => 'https://app.example.com']);
|
|
};
|
|
$newRecord = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $newApplication);
|
|
|
|
expect(sentDnsRequests('DELETE'))->toBe(1)
|
|
->and(sentDnsRequests('POST'))->toBe(2)
|
|
->and($oldRecord->fresh())->toBeNull()
|
|
->and($newRecord->fresh())->not->toBeNull()
|
|
->and($newRecord->provider_record_id)->not->toBe($oldRecord->provider_record_id)
|
|
->and($newRecord->owned)->toBeTrue()
|
|
->and($newRecord->references()->pluck('resource_id')->all())->toBe([$newApplication->id])
|
|
->and($cloudflare['records'])->toHaveCount(1);
|
|
});
|
|
|
|
test('the release keeps the record when a new reference appears before the lock is taken', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$lock = new InterleavingManagedDnsHostnameLock;
|
|
app()->instance(ManagedDnsHostnameLock::class, $lock);
|
|
$application = createDnsTestApplication($this, 'https://moved.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$other = createDnsTestApplication($this, 'https://unrelated.example.org');
|
|
|
|
$lock->beforeNextRun = fn () => $record->addReference($other);
|
|
$result = app(ManagedDnsRecordCleanup::class)->releaseHostname($application, 'app.example.com', $this->team->id);
|
|
|
|
expect($result)->toBeNull()
|
|
->and(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->references()->pluck('resource_id')->all())->toBe([$other->id]);
|
|
});
|
|
|
|
test('the release keeps the record when the resource uses the hostname again before the lock is taken', function () {
|
|
$cloudflare = fakeCloudflareDns();
|
|
$lock = new InterleavingManagedDnsHostnameLock;
|
|
app()->instance(ManagedDnsHostnameLock::class, $lock);
|
|
$application = createDnsTestApplication($this, 'https://moved.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
|
|
$lock->beforeNextRun = fn () => $application->update(['fqdn' => 'https://app.example.com']);
|
|
$result = app(ManagedDnsRecordCleanup::class)->releaseHostname($application, 'app.example.com', $this->team->id);
|
|
|
|
expect($result)->toBeNull()
|
|
->and(sentDnsRequests('DELETE'))->toBe(0)
|
|
->and($cloudflare['records'])->toHaveCount(1)
|
|
->and($record->references()->pluck('resource_id')->all())->toBe([$application->id]);
|
|
});
|
|
|
|
test('a release reports busy when the lock is not free in time', function () {
|
|
fakeCloudflareDns();
|
|
app()->instance(ManagedDnsHostnameLock::class, new ManagedDnsHostnameLock(waitSeconds: 0));
|
|
$application = createDnsTestApplication($this, 'https://moved.example.com');
|
|
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
$heldLock = Cache::lock(ManagedDnsHostnameLock::key($this->team->id, 'app.example.com'), 60);
|
|
$heldLock->get();
|
|
|
|
$result = app(ManagedDnsRecordCleanup::class)->releaseHostname($application, 'app.example.com', $this->team->id);
|
|
|
|
expect($result)->toBe(ManagedDnsDeletionResult::Busy)
|
|
->and($result->shouldRetry())->toBeTrue()
|
|
->and($record->fresh())->not->toBeNull();
|
|
$heldLock->release();
|
|
});
|
|
});
|
|
|
|
describe('domain edits after the session team changes', function () {
|
|
beforeEach(function () {
|
|
Queue::fake([CheckDomainDnsJob::class, ReleaseRemovedDnsHostnamesJob::class]);
|
|
fakeCloudflareDns();
|
|
$this->sessionTeam = Team::factory()->create();
|
|
$this->sessionTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
|
$this->user->unsetRelation('teams');
|
|
});
|
|
|
|
function assertReleaseQueuedForTeam(int $teamId, string $resourceType, int $resourceId): void
|
|
{
|
|
Queue::assertPushed(ReleaseRemovedDnsHostnamesJob::class, fn (ReleaseRemovedDnsHostnamesJob $job): bool => $job->teamId === $teamId
|
|
&& $job->resourceType === $resourceType
|
|
&& $job->resourceId === $resourceId
|
|
&& $job->hostnames === ['app.example.com']);
|
|
}
|
|
|
|
test('the application general form queues the release for the resource team', function () {
|
|
$application = createDnsTestApplication($this, 'https://app.example.com');
|
|
app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
|
|
|
|
$component = Livewire::test(General::class, ['application' => $application]);
|
|
session(['currentTeam' => $this->sessionTeam]);
|
|
$component->set('fqdn', 'https://new.example.com')->call('submit')->assertHasNoErrors();
|
|
|
|
expect($application->fresh()->fqdn)->toBe('https://new.example.com');
|
|
assertReleaseQueuedForTeam($this->team->id, $application->getMorphClass(), $application->id);
|
|
});
|
|
|
|
test('the preview domain form queues the release for the resource team', function () {
|
|
$application = createDnsTestApplication($this, 'https://main.example.com');
|
|
$preview = ApplicationPreview::create([
|
|
'application_id' => $application->id, 'pull_request_id' => 7,
|
|
'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/7', 'fqdn' => 'https://app.example.com',
|
|
]);
|
|
app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $preview);
|
|
|
|
$component = Livewire::test(PreviewDomains::class, ['preview' => $preview]);
|
|
session(['currentTeam' => $this->sessionTeam]);
|
|
$component->call('startEdit', 0)
|
|
->set('editingDomainParts.host', 'new.example.com')
|
|
->call('updateDomain')
|
|
->assertHasNoErrors();
|
|
|
|
expect($preview->fresh()->fqdn)->toContain('new.example.com');
|
|
assertReleaseQueuedForTeam($this->team->id, $preview->getMorphClass(), $preview->id);
|
|
});
|
|
|
|
test('the service application form queues the release for the resource team', function () {
|
|
$serviceApplication = editReleaseService($this, 'https://app.example.com');
|
|
app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $serviceApplication);
|
|
|
|
$component = Livewire::test(ServiceIndex::class, ['serviceApplication' => $serviceApplication->fresh()]);
|
|
session(['currentTeam' => $this->sessionTeam]);
|
|
$component->set('fqdn', 'https://new.example.com')->call('submitApplication')->assertHasNoErrors();
|
|
|
|
expect($serviceApplication->fresh()->fqdn)->toBe('https://new.example.com');
|
|
assertReleaseQueuedForTeam($this->team->id, $serviceApplication->getMorphClass(), $serviceApplication->id);
|
|
});
|
|
});
|