mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-27 17:55:59 -04:00
feat(previews): add preview domain management and DNS status tracking
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Jobs;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -23,7 +24,7 @@ class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(
|
||||
public Application|ServiceApplication $resource,
|
||||
public Application|ApplicationPreview|ServiceApplication $resource,
|
||||
public string $statusKey,
|
||||
public string $url,
|
||||
public ?Server $server,
|
||||
|
||||
@@ -1007,6 +1007,7 @@ class Domains extends Component
|
||||
$skipDns = ! $this->dnsValidationEnabled
|
||||
|| ! $server
|
||||
|| $this->application->additional_servers->count() > 0;
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
$url = $row['url'] ?? null;
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class PreviewDomains extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public ApplicationPreview $preview;
|
||||
|
||||
public array $domainRows = [];
|
||||
|
||||
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
|
||||
|
||||
public ?string $newDomainService = null;
|
||||
|
||||
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
|
||||
|
||||
public ?int $editingIndex = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshDomains();
|
||||
if ($this->preview->application->build_pack === 'dockercompose') {
|
||||
$this->newDomainService = $this->composeServices()[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.preview-domains', [
|
||||
'isCompose' => $this->preview->application->build_pack === 'dockercompose',
|
||||
'composeServices' => $this->composeServices(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function addDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
if ($this->preview->application->build_pack === 'dockercompose'
|
||||
&& ($this->newDomainService === null || ! in_array($this->newDomainService, $this->composeServices(), true))) {
|
||||
$this->addError('newDomainService', 'Select a valid Compose service.');
|
||||
|
||||
return;
|
||||
}
|
||||
$domain = $this->validatedDomain($this->newDomainParts, 'newDomainParts.host');
|
||||
if ($domain === null) {
|
||||
return;
|
||||
}
|
||||
if (collect($this->domainRows)->contains(fn (array $row): bool => $row['url'] === $domain && $row['service'] === $this->newDomainService)) {
|
||||
$this->addError('newDomainParts.host', 'This domain is already configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->domainRows[] = $this->makeRow($domain, $this->newDomainService);
|
||||
$index = array_key_last($this->domainRows);
|
||||
$checkId = new_public_id();
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$this->newDomainParts = DomainUrlParts::empty();
|
||||
$this->newDomainService = $this->preview->application->build_pack === 'dockercompose'
|
||||
? ($this->composeServices()[0] ?? null)
|
||||
: null;
|
||||
$this->dispatch('close-modal');
|
||||
|
||||
try {
|
||||
$server = $this->preview->application->destination?->server;
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->preview,
|
||||
$this->statusKey($domain, $this->domainRows[$index]['service']),
|
||||
$domain,
|
||||
$server,
|
||||
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
|
||||
$checkId,
|
||||
$this->preview->application->additional_servers->count() > 0,
|
||||
);
|
||||
$this->dispatch('success', 'Domain added. DNS check started.');
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->persistDnsStatuses();
|
||||
$this->dispatch('error', 'Domain added, but the DNS check could not be started. Try again from the preview domains list.');
|
||||
}
|
||||
}
|
||||
|
||||
public function generateDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->preview->refresh();
|
||||
if ($this->preview->application->build_pack === 'dockercompose') {
|
||||
if ($this->newDomainService === null && $this->domainRows === []) {
|
||||
$this->preview->generate_preview_fqdn_compose(generateWithoutApplicationDomain: true);
|
||||
} else {
|
||||
$service = $this->newDomainService ?? data_get($this->domainRows, '0.service');
|
||||
foreach ($this->generateComposeDomains((string) $service) as $domain) {
|
||||
$alreadyExists = collect($this->domainRows)->contains(
|
||||
fn (array $row): bool => $row['url'] === $domain && $row['service'] === $service
|
||||
);
|
||||
if (! $alreadyExists) {
|
||||
$this->domainRows[] = $this->makeRow($domain, $service);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->preview->generate_preview_fqdn(generateWithoutApplicationDomain: true);
|
||||
}
|
||||
$this->refreshDomains();
|
||||
$this->dispatch('success', 'Domain generated.');
|
||||
}
|
||||
|
||||
public function startEdit(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index])) {
|
||||
return;
|
||||
}
|
||||
$this->editingIndex = $index;
|
||||
$this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']);
|
||||
$this->dispatch('open-preview-domain-edit');
|
||||
}
|
||||
|
||||
public function updateDomain(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
|
||||
return;
|
||||
}
|
||||
$domain = $this->validatedDomain($this->editingDomainParts, 'editingDomainParts.host');
|
||||
if ($domain === null) {
|
||||
return;
|
||||
}
|
||||
$this->domainRows[$this->editingIndex]['url'] = $domain;
|
||||
$this->domainRows[$this->editingIndex]['dns_status'] = 'pending';
|
||||
$this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.';
|
||||
$index = $this->editingIndex;
|
||||
$this->editingIndex = null;
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$this->dispatch('close-preview-domain-edit');
|
||||
$this->dispatch('success', 'Domain updated.');
|
||||
$this->checkDomainDns($index);
|
||||
}
|
||||
|
||||
public function removeDomain(int $index): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
if (! isset($this->domainRows[$index])) {
|
||||
return;
|
||||
}
|
||||
unset($this->domainRows[$index]);
|
||||
$this->domainRows = array_values($this->domainRows);
|
||||
if (! $this->persistDomains()) {
|
||||
return;
|
||||
}
|
||||
$this->dispatch('success', 'Domain removed.');
|
||||
}
|
||||
|
||||
public function removeDomainByKey(string $domainKey): void
|
||||
{
|
||||
$index = collect($this->domainRows)->search(
|
||||
fn (array $row): bool => hash_equals($domainKey, $this->statusKey($row['url'], $row['service']))
|
||||
);
|
||||
|
||||
if ($index === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeDomain((int) $index);
|
||||
}
|
||||
|
||||
public function checkAllDns(): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
foreach (array_keys($this->domainRows) as $index) {
|
||||
$this->applyDnsCheck($index);
|
||||
}
|
||||
$this->persistDnsStatuses();
|
||||
}
|
||||
|
||||
public function checkDomainDns(int $index): void
|
||||
{
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->applyDnsCheck($index);
|
||||
$this->persistDnsStatuses();
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
{
|
||||
$checkingRows = collect($this->domainRows)
|
||||
->where('dns_status', 'checking')
|
||||
->values();
|
||||
|
||||
$this->refreshDomains();
|
||||
|
||||
foreach ($checkingRows as $checkingRow) {
|
||||
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
|
||||
&& ($row['service'] ?? null) === ($checkingRow['service'] ?? null));
|
||||
|
||||
if (! is_array($row) || $row['dns_status'] === 'checking') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchDnsCheckNotification(string $url, string $status): void
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST) ?: $url;
|
||||
|
||||
match ($status) {
|
||||
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
|
||||
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
|
||||
default => $this->dispatch('info', "DNS check skipped for {$host}."),
|
||||
};
|
||||
}
|
||||
|
||||
private function applyDnsCheck(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index])) {
|
||||
return;
|
||||
}
|
||||
$result = $this->checkUrlDns($this->domainRows[$index]['url'], (string) $index);
|
||||
$this->domainRows[$index]['dns_status'] = $result['status'];
|
||||
$this->domainRows[$index]['dns_message'] = $result['message'];
|
||||
}
|
||||
|
||||
private function checkUrlDns(string $url, string $key = 'domain'): array
|
||||
{
|
||||
$server = $this->preview->application->destination?->server;
|
||||
|
||||
return CheckDomainDns::run(
|
||||
[$key => $url],
|
||||
$server,
|
||||
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
|
||||
$this->preview->application->additional_servers->count() > 0,
|
||||
)[$key];
|
||||
}
|
||||
|
||||
private function refreshDomains(): void
|
||||
{
|
||||
$this->preview->refresh();
|
||||
$statuses = $this->preview->domain_dns_statuses ?? [];
|
||||
$rows = [];
|
||||
if ($this->preview->application->build_pack === 'dockercompose') {
|
||||
foreach (json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [] as $service => $entry) {
|
||||
foreach ($this->splitDomains(composeDomainEntryString($entry)) as $url) {
|
||||
$rows[] = $this->makeRow($url, (string) $service, $statuses);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($this->splitDomains($this->preview->fqdn) as $url) {
|
||||
$rows[] = $this->makeRow($url, null, $statuses);
|
||||
}
|
||||
}
|
||||
$this->domainRows = $rows;
|
||||
}
|
||||
|
||||
private function persistDomains(): bool
|
||||
{
|
||||
if ($this->preview->application->build_pack === 'dockercompose') {
|
||||
try {
|
||||
$composeServices = $this->composeServices(failOnError: true);
|
||||
} catch (\Throwable) {
|
||||
$this->refreshDomains();
|
||||
$this->dispatch('error', 'Compose configuration could not be parsed. Preview domains were not changed.');
|
||||
|
||||
return false;
|
||||
}
|
||||
$domains = collect($composeServices)
|
||||
->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']])
|
||||
->all();
|
||||
$validRows = collect($this->domainRows)
|
||||
->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true));
|
||||
foreach ($validRows->groupBy('service') as $service => $rows) {
|
||||
$domains[$service] = ['domain' => $rows->pluck('url')->implode(',')];
|
||||
}
|
||||
$this->preview->docker_compose_domains = json_encode($domains);
|
||||
$this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null;
|
||||
} else {
|
||||
$this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null;
|
||||
}
|
||||
$this->preview->save();
|
||||
$this->persistDnsStatuses();
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('previewDomainsChanged');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function persistDnsStatuses(): void
|
||||
{
|
||||
$statuses = [];
|
||||
foreach ($this->domainRows as $row) {
|
||||
$statuses[$this->statusKey($row['url'], $row['service'])] = [
|
||||
'status' => $row['dns_status'],
|
||||
'message' => $row['dns_message'],
|
||||
'check_id' => $row['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$statuses): void {
|
||||
$preview = ApplicationPreview::query()->lockForUpdate()->findOrFail($this->preview->id);
|
||||
$storedStatuses = $preview->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($statuses as $key => $status) {
|
||||
$storedStatus = $storedStatuses[$key] ?? null;
|
||||
if (! is_array($storedStatus)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$localCheckId = $status['check_id'] ?? null;
|
||||
$storedCheckId = $storedStatus['check_id'] ?? null;
|
||||
|
||||
if (($storedCheckId !== null && $localCheckId !== $storedCheckId)
|
||||
|| ($status['status'] === 'checking' && ($storedStatus['status'] ?? null) !== 'checking')) {
|
||||
$statuses[$key] = $storedStatus;
|
||||
}
|
||||
}
|
||||
|
||||
$preview->domain_dns_statuses = $statuses ?: null;
|
||||
$preview->save();
|
||||
});
|
||||
|
||||
$this->preview->domain_dns_statuses = $statuses ?: null;
|
||||
}
|
||||
|
||||
private function validatedDomain(array $parts, string $errorKey): ?string
|
||||
{
|
||||
$domain = DomainUrlParts::compose(...$parts);
|
||||
$validator = validator(['domain' => $domain], ['domain' => ValidationPatterns::applicationDomainRules()]);
|
||||
if ($validator->fails()) {
|
||||
$this->addError($errorKey, $validator->errors()->first('domain'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return ValidationPatterns::normalizeApplicationDomains($domain);
|
||||
}
|
||||
|
||||
private function makeRow(string $url, ?string $service, array $statuses = []): array
|
||||
{
|
||||
$status = $statuses[$this->statusKey($url, $service)] ?? [];
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'service' => $service,
|
||||
'dns_status' => $status['status'] ?? 'pending',
|
||||
'dns_message' => $status['message'] ?? 'DNS has not been checked yet.',
|
||||
'check_id' => $status['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function statusKey(string $url, ?string $service): string
|
||||
{
|
||||
return hash('sha256', $url.'|'.($service ?? ''));
|
||||
}
|
||||
|
||||
private function splitDomains(?string $domains): array
|
||||
{
|
||||
return str($domains)->explode(',')->map(fn ($domain) => trim((string) $domain))->filter()->values()->all();
|
||||
}
|
||||
|
||||
private function generateComposeDomains(string $service): array
|
||||
{
|
||||
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
|
||||
$domainString = getComposeServiceDomainString($applicationDomains, $service);
|
||||
|
||||
if (empty($domainString)) {
|
||||
$domainString = generateUrl(
|
||||
server: $this->preview->application->destination->server,
|
||||
random: str($service)->slug().'-'.$this->preview->application->uuid,
|
||||
);
|
||||
}
|
||||
|
||||
return collect($this->splitDomains($domainString))->map(function (string $domain): string {
|
||||
$url = Url::fromString($domain);
|
||||
$generatedDomain = str_replace('{{random}}', new_public_id(), $this->preview->application->preview_url_template);
|
||||
$generatedDomain = str_replace('{{domain}}', $url->getHost(), $generatedDomain);
|
||||
$generatedDomain = str_replace('{{pr_id}}', (string) $this->preview->pull_request_id, $generatedDomain);
|
||||
$port = $url->getPort() !== null ? ':'.$url->getPort() : '';
|
||||
$path = ! in_array($url->getPath(), ['', '/'], true) ? $url->getPath() : '';
|
||||
|
||||
return "{$url->getScheme()}://{$generatedDomain}{$port}{$path}";
|
||||
})->all();
|
||||
}
|
||||
|
||||
private function composeServices(bool $failOnError = false): array
|
||||
{
|
||||
try {
|
||||
$parsedCompose = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
|
||||
$services = data_get($parsedCompose, 'services', []);
|
||||
if (! is_iterable($services)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$usesLegacyServiceKeys = (int) $this->preview->application->compose_parsing_version < 3;
|
||||
$previewSuffix = '-pr-'.$this->preview->pull_request_id;
|
||||
$serviceNames = [];
|
||||
foreach ($services as $serviceName => $service) {
|
||||
if (isDatabaseImage(data_get($service, 'image'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$serviceName = (string) $serviceName;
|
||||
if ($usesLegacyServiceKeys && str_ends_with($serviceName, $previewSuffix)) {
|
||||
$serviceName = substr($serviceName, 0, -strlen($previewSuffix));
|
||||
}
|
||||
$serviceNames[] = $serviceName;
|
||||
}
|
||||
|
||||
return array_values(array_unique($serviceNames));
|
||||
} catch (\Throwable $exception) {
|
||||
if ($failOnError) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use App\Events\ServiceStatusChanged;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
@@ -16,6 +15,8 @@ class Previews extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains'];
|
||||
|
||||
public Application $application;
|
||||
|
||||
public string $deployment_uuid;
|
||||
@@ -26,16 +27,6 @@ class Previews extends Component
|
||||
|
||||
public int $rate_limit_remaining;
|
||||
|
||||
public $domainConflicts = [];
|
||||
|
||||
public $showDomainConflictModal = false;
|
||||
|
||||
public $forceSaveDomains = false;
|
||||
|
||||
public $pendingPreviewId = null;
|
||||
|
||||
public array $previewFqdns = [];
|
||||
|
||||
public array $previewDockerTags = [];
|
||||
|
||||
public ?int $manualPullRequestId = null;
|
||||
@@ -43,7 +34,6 @@ class Previews extends Component
|
||||
public ?string $manualDockerTag = null;
|
||||
|
||||
protected $rules = [
|
||||
'previewFqdns.*' => 'string|nullable',
|
||||
'previewDockerTags.*' => 'string|nullable',
|
||||
'manualPullRequestId' => 'integer|min:1|nullable',
|
||||
'manualDockerTag' => 'string|nullable',
|
||||
@@ -53,31 +43,23 @@ class Previews extends Component
|
||||
{
|
||||
$this->pull_requests = collect();
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->syncData(false);
|
||||
$this->syncDockerTags();
|
||||
}
|
||||
|
||||
private function syncData(bool $toModel = false): void
|
||||
private function syncDockerTags(): void
|
||||
{
|
||||
if ($toModel) {
|
||||
foreach ($this->previewFqdns as $key => $fqdn) {
|
||||
$preview = $this->application->previews->get($key);
|
||||
if ($preview) {
|
||||
$preview->fqdn = $fqdn;
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->previewFqdns = [];
|
||||
$this->previewDockerTags = [];
|
||||
foreach ($this->application->previews as $key => $preview) {
|
||||
$this->previewFqdns[$key] = $preview->fqdn;
|
||||
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
|
||||
}
|
||||
$this->previewDockerTags = [];
|
||||
foreach ($this->application->previews as $key => $preview) {
|
||||
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshPreviewDomains(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
$this->syncDockerTags();
|
||||
}
|
||||
|
||||
public function load_prs()
|
||||
{
|
||||
try {
|
||||
@@ -92,103 +74,28 @@ class Previews extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmDomainUsage()
|
||||
{
|
||||
$this->forceSaveDomains = true;
|
||||
$this->showDomainConflictModal = false;
|
||||
if ($this->pendingPreviewId) {
|
||||
$this->save_preview($this->pendingPreviewId);
|
||||
$this->pendingPreviewId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function save_preview($preview_id)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$success = true;
|
||||
$preview = $this->application->previews->find($preview_id);
|
||||
|
||||
if (! $preview) {
|
||||
throw new \Exception('Preview not found');
|
||||
}
|
||||
|
||||
// Find the key for this preview in the collection
|
||||
$previewKey = $this->application->previews->search(function ($item) use ($preview_id) {
|
||||
return $item->id == $preview_id;
|
||||
});
|
||||
|
||||
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
|
||||
$this->validate([
|
||||
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
|
||||
]);
|
||||
|
||||
$fqdn = $this->previewFqdns[$previewKey];
|
||||
|
||||
if (! empty($fqdn)) {
|
||||
$fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
|
||||
$this->previewFqdns[$previewKey] = $fqdn;
|
||||
|
||||
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
|
||||
$server = $this->application->destination->server;
|
||||
$target = serverDnsTargetIp($server) ?? $server->ip;
|
||||
$guidance = dnsMismatchGuidanceMessage($target, $target);
|
||||
$this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
|
||||
$success = false;
|
||||
}
|
||||
|
||||
// Check for domain conflicts if not forcing save
|
||||
if (! $this->forceSaveDomains) {
|
||||
$result = checkDomainUsage(resource: $this->application, domain: $fqdn);
|
||||
if ($result['hasConflicts']) {
|
||||
$this->domainConflicts = $result['conflicts'];
|
||||
$this->showDomainConflictModal = true;
|
||||
$this->pendingPreviewId = $preview_id;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Reset the force flag after using it
|
||||
$this->forceSaveDomains = false;
|
||||
}
|
||||
}
|
||||
if ($previewKey === false) {
|
||||
throw new \Exception('Preview not found');
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
$this->syncData(true);
|
||||
$preview->save();
|
||||
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function generate_preview($preview_id)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$preview = $this->application->previews->find($preview_id);
|
||||
if (! $preview) {
|
||||
$this->dispatch('error', 'Preview not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->application->build_pack === 'dockercompose') {
|
||||
$preview->generate_preview_fqdn_compose();
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
$this->dispatch('success', 'Domain generated.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$preview->generate_preview_fqdn();
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('success', 'Domain generated.');
|
||||
$this->validateOnly("previewDockerTags.{$previewKey}");
|
||||
$preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null;
|
||||
$preview->save();
|
||||
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -211,7 +118,7 @@ class Previews extends Component
|
||||
}
|
||||
$found->generate_preview_fqdn_compose();
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
$this->syncDockerTags();
|
||||
} else {
|
||||
$this->setDeploymentUuid();
|
||||
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
@@ -227,9 +134,9 @@ class Previews extends Component
|
||||
$found->docker_registry_image_tag = $docker_registry_image_tag;
|
||||
$found->save();
|
||||
}
|
||||
$found->generate_preview_fqdn();
|
||||
$found->generate_preview_fqdn(generateWithoutApplicationDomain: true);
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
$this->syncDockerTags();
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('success', 'Preview added.');
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class PreviewsCompose extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $service;
|
||||
|
||||
public $serviceName;
|
||||
|
||||
public ApplicationPreview $preview;
|
||||
|
||||
public ?string $domain = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->domain = data_get($this->service, 'domain');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.previews-compose');
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->preview->application);
|
||||
$this->validate([
|
||||
'domain' => ValidationPatterns::applicationDomainRules(),
|
||||
]);
|
||||
|
||||
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
|
||||
$this->persistPreviewDomain($this->domain);
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('success', 'Domain saved.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function generate()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->preview->application);
|
||||
|
||||
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
|
||||
$domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName);
|
||||
|
||||
// If no domain is set in the main application, generate a default domain
|
||||
if (empty($domain_string)) {
|
||||
$server = $this->preview->application->destination->server;
|
||||
$template = $this->preview->application->preview_url_template;
|
||||
$random = new_public_id();
|
||||
|
||||
// Generate a unique domain like main app services do
|
||||
$generated_fqdn = generateUrl(server: $server, random: $random);
|
||||
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
||||
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
|
||||
} else {
|
||||
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
|
||||
throw new \InvalidArgumentException($error);
|
||||
}
|
||||
|
||||
// Use the existing domain from the main application
|
||||
// Handle multiple domains separated by commas
|
||||
$domain_list = ValidationPatterns::applicationDomainList($domain_string);
|
||||
$preview_fqdns = [];
|
||||
$template = $this->preview->application->preview_url_template;
|
||||
$random = new_public_id();
|
||||
|
||||
foreach ($domain_list as $single_domain) {
|
||||
$single_domain = trim($single_domain);
|
||||
if (empty($single_domain)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = Url::fromString($single_domain);
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
||||
$preview_fqdns[] = "$schema://$preview_fqdn{$port}";
|
||||
}
|
||||
|
||||
$preview_fqdn = implode(',', $preview_fqdns);
|
||||
}
|
||||
|
||||
$this->domain = $preview_fqdn;
|
||||
$this->persistPreviewDomain($this->domain);
|
||||
|
||||
$this->dispatch('update_links');
|
||||
$this->dispatch('success', 'Domain generated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function persistPreviewDomain(?string $domain): void
|
||||
{
|
||||
$docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: [];
|
||||
$serviceNames = $this->previewServiceNames($docker_compose_domains);
|
||||
$storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames)
|
||||
?? (string) $this->serviceName;
|
||||
|
||||
$docker_compose_domains = putComposeServiceDomain(
|
||||
$docker_compose_domains,
|
||||
$storageKey,
|
||||
$domain,
|
||||
$serviceNames,
|
||||
);
|
||||
$docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames);
|
||||
|
||||
$this->serviceName = $storageKey;
|
||||
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
|
||||
$this->preview->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $previewDomains
|
||||
* @return list<string>
|
||||
*/
|
||||
private function previewServiceNames(array $previewDomains): array
|
||||
{
|
||||
$parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
|
||||
$fromCompose = collect(data_get($parsedServices, 'services', []))
|
||||
->keys()
|
||||
->map(function ($serviceName) {
|
||||
return str((string) $serviceName)
|
||||
->replaceLast('-pr-'.$this->preview->pull_request_id, '')
|
||||
->toString();
|
||||
})
|
||||
->all();
|
||||
|
||||
$domainKeys = collect(array_keys($previewDomains))
|
||||
->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []))
|
||||
->map(fn ($name) => (string) $name);
|
||||
$unmapped = $domainKeys
|
||||
->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null)
|
||||
->all();
|
||||
|
||||
return collect($fromCompose)
|
||||
->merge(preferredComposeServiceNamesFromDomainKeys(
|
||||
$fromCompose === [] ? $domainKeys->all() : $unmapped
|
||||
))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -1447,6 +1447,7 @@ class Domains extends Component
|
||||
$urlSet = array_fill_keys($urls, true);
|
||||
$server = $this->service->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled || ! $server;
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
$url = $row['url'] ?? null;
|
||||
|
||||
@@ -23,10 +23,12 @@ class ApplicationPreview extends BaseModel
|
||||
'docker_compose_domains',
|
||||
'docker_registry_image_tag',
|
||||
'last_online_at',
|
||||
'domain_dns_statuses',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'domain_dns_statuses' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
@@ -105,13 +107,21 @@ class ApplicationPreview extends BaseModel
|
||||
return $this->morphMany(LocalPersistentVolume::class, 'resource');
|
||||
}
|
||||
|
||||
public function generate_preview_fqdn()
|
||||
public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = false)
|
||||
{
|
||||
if ($this->application->fqdn) {
|
||||
if (str($this->application->fqdn)->contains(',')) {
|
||||
$url = Url::fromString(str($this->application->fqdn)->explode(',')[0]);
|
||||
$applicationFqdn = $this->application->fqdn;
|
||||
if (! $applicationFqdn && $generateWithoutApplicationDomain) {
|
||||
$applicationFqdn = generateUrl(
|
||||
server: $this->application->destination->server,
|
||||
random: $this->application->uuid,
|
||||
);
|
||||
}
|
||||
|
||||
if ($applicationFqdn) {
|
||||
if (str($applicationFqdn)->contains(',')) {
|
||||
$url = Url::fromString(str($applicationFqdn)->explode(',')[0]);
|
||||
} else {
|
||||
$url = Url::fromString($this->application->fqdn);
|
||||
$url = Url::fromString($applicationFqdn);
|
||||
}
|
||||
$template = $this->application->preview_url_template;
|
||||
$host = $url->getHost();
|
||||
@@ -132,7 +142,7 @@ class ApplicationPreview extends BaseModel
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function generate_preview_fqdn_compose()
|
||||
public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDomain = false)
|
||||
{
|
||||
$applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: [];
|
||||
$previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: [];
|
||||
@@ -174,8 +184,15 @@ class ApplicationPreview extends BaseModel
|
||||
foreach ($serviceNames as $service_name) {
|
||||
$domain_string = getComposeServiceDomainString($applicationDomains, $service_name);
|
||||
|
||||
// If domain string is empty or null, don't auto-generate domain
|
||||
// Only generate domains when main app already has domains set
|
||||
if (empty($domain_string)) {
|
||||
if ($generateWithoutApplicationDomain) {
|
||||
$domain_string = generateUrl(
|
||||
server: $this->application->destination->server,
|
||||
random: str($service_name)->slug().'-'.$this->application->uuid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($domain_string)) {
|
||||
$docker_compose_domains = putComposeServiceDomain(
|
||||
$docker_compose_domains,
|
||||
|
||||
Reference in New Issue
Block a user