mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
Merge remote-tracking branch 'origin/main' into 11425-scheduled-task-failure-alerts
This commit is contained in:
@@ -45,15 +45,17 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
existing_pr=$(gh pr list --base next --head main --state open --json url --jq '.[0].url')
|
||||
sync_branch='automation/sync-main-to-next'
|
||||
existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url')
|
||||
if [ -n "$existing_pr" ]; then
|
||||
echo "A main to next pull request already exists: $existing_pr"
|
||||
else
|
||||
git push --force origin origin/main:"refs/heads/$sync_branch"
|
||||
gh pr create \
|
||||
--base next \
|
||||
--head main \
|
||||
--head "$sync_branch" \
|
||||
--title 'chore: merge main into next' \
|
||||
--body 'This pull request was created automatically because main could not be merged into next without conflicts.'
|
||||
--body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.'
|
||||
fi
|
||||
|
||||
echo 'main could not be merged into next without conflicts.'
|
||||
|
||||
@@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor
|
||||
## Local Development
|
||||
To build and run Coolify locally, see: [Development](./DEVELOPMENT.md)
|
||||
|
||||
### Testing the Coolify Helper Locally
|
||||
|
||||
Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications.
|
||||
|
||||
Run the complete workflow:
|
||||
|
||||
```bash
|
||||
./scripts/dev-helper test my-helper-test
|
||||
```
|
||||
|
||||
This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications.
|
||||
|
||||
You can also run each step separately:
|
||||
|
||||
```bash
|
||||
./scripts/dev-helper build my-helper-test
|
||||
./scripts/dev-helper use my-helper-test
|
||||
./scripts/dev-helper verify my-helper-test
|
||||
./scripts/dev-helper deploy my-helper-test
|
||||
```
|
||||
|
||||
Clear the helper override when finished:
|
||||
|
||||
```bash
|
||||
./scripts/dev-helper reset
|
||||
```
|
||||
|
||||
The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name.
|
||||
|
||||
### macOS Development with Lima
|
||||
Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS.
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class CheckDomainDns
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
|
||||
*/
|
||||
public function handle(
|
||||
array $entries,
|
||||
?Server $server,
|
||||
?string $expectedIp,
|
||||
bool $skipForMultipleServers = false,
|
||||
int $timeoutSeconds = 5,
|
||||
): array {
|
||||
if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp);
|
||||
}
|
||||
|
||||
if (! $server) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null);
|
||||
}
|
||||
|
||||
if ($skipForMultipleServers) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp);
|
||||
}
|
||||
|
||||
$deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000);
|
||||
$dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers'))
|
||||
->explode(',')
|
||||
->map(fn ($dnsServer) => trim((string) $dnsServer))
|
||||
->filter()
|
||||
->values();
|
||||
$results = [];
|
||||
|
||||
foreach ($entries as $key => $url) {
|
||||
$results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $dnsServers
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array
|
||||
{
|
||||
try {
|
||||
$host = Url::fromString($url)->getHost();
|
||||
} catch (\Throwable) {
|
||||
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
|
||||
}
|
||||
if (str($host)->contains('sslip.io')) {
|
||||
return $this->result('ok', 'DNS looks correct.', $expectedIp);
|
||||
}
|
||||
|
||||
$type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
|
||||
|
||||
foreach ($dnsServers as $dnsServer) {
|
||||
$remainingNanoseconds = $deadline - hrtime(true);
|
||||
if ($remainingNanoseconds < 1_000_000_000) {
|
||||
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
|
||||
}
|
||||
|
||||
try {
|
||||
$query = app()->make(DNSQuery::class, [
|
||||
'server' => $dnsServer,
|
||||
'port' => 53,
|
||||
'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)),
|
||||
]);
|
||||
$records = $query->query($host, $type);
|
||||
|
||||
if ($records === false || $query->hasError()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
if ($record->getType() !== $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) {
|
||||
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp);
|
||||
}
|
||||
|
||||
private function successMessage(Server $server, ?string $expectedIp): string
|
||||
{
|
||||
if (
|
||||
filled($expectedIp)
|
||||
&& filled($server->ip)
|
||||
&& $server->ip !== $expectedIp
|
||||
&& filter_var($server->ip, FILTER_VALIDATE_IP) === false
|
||||
) {
|
||||
return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare).";
|
||||
}
|
||||
|
||||
return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function result(string $status, string $message, ?string $expectedIp): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'expected_ip' => $expectedIp,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
|
||||
*/
|
||||
private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array
|
||||
{
|
||||
$result = $this->result($status, $message, $expectedIp);
|
||||
|
||||
return array_fill_keys(array_keys($entries), $result);
|
||||
}
|
||||
}
|
||||
@@ -1604,7 +1604,12 @@ class ApplicationsController extends Controller
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first();
|
||||
$githubApp = GithubApp::where('uuid', $githubAppUuid)
|
||||
->where(function ($query) use ($teamId) {
|
||||
$query->where('team_id', $teamId)
|
||||
->orWhere('is_system_wide', true);
|
||||
})
|
||||
->first();
|
||||
if (! $githubApp) {
|
||||
return response()->json(['message' => 'Github App not found.'], 404);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(
|
||||
public Application|ServiceApplication $resource,
|
||||
public string $statusKey,
|
||||
public string $url,
|
||||
public ?Server $server,
|
||||
public ?string $expectedIp,
|
||||
public string $checkId,
|
||||
public bool $skipForMultipleServers = false,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->persistResults(CheckDomainDns::run(
|
||||
[$this->statusKey => $this->url],
|
||||
$this->server,
|
||||
$this->expectedIp,
|
||||
$this->skipForMultipleServers,
|
||||
));
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$this->persistResults([
|
||||
$this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function status(string $status, string $message): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'expected_ip' => $this->expectedIp,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}> $results
|
||||
*/
|
||||
private function persistResults(array $results): void
|
||||
{
|
||||
DB::transaction(function () use ($results): void {
|
||||
$resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey());
|
||||
if (! $resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = $resource->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($results as $key => $result) {
|
||||
if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statuses[$key] = $result;
|
||||
}
|
||||
|
||||
$resource->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$resource->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
@@ -10,6 +12,7 @@ use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Domains extends Component
|
||||
@@ -141,6 +144,39 @@ class Domains extends Component
|
||||
$this->loadDomainState();
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
|
||||
protected 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}."),
|
||||
};
|
||||
}
|
||||
|
||||
public function toggleNoindexDomain(string $domain, string|bool $indexing): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
@@ -464,6 +500,7 @@ class Domains extends Component
|
||||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
@@ -478,6 +515,7 @@ class Domains extends Component
|
||||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
'checked_at' => null,
|
||||
'check_id' => null,
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
@@ -533,6 +571,8 @@ class Domains extends Component
|
||||
|| ! $server
|
||||
|| $this->application->additional_servers->count() > 0;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$reason = ! $this->dnsValidationEnabled
|
||||
@@ -548,7 +588,11 @@ class Domains extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $row['url'], $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistDomainDnsStatuses();
|
||||
@@ -575,45 +619,50 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server);
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, string $url, Server $server): void
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
{
|
||||
$target = $this->dnsTargetLabel();
|
||||
$this->applyDnsStatuses([$index], $server);
|
||||
}
|
||||
|
||||
try {
|
||||
$isValid = validateDNSEntry($url, $server);
|
||||
if ($isValid) {
|
||||
$this->domainRows[$index]['dns_status'] = 'ok';
|
||||
$this->domainRows[$index]['dns_message'] = $target
|
||||
? "DNS points to {$target} (or Cloudflare)."
|
||||
: 'DNS looks correct.';
|
||||
} else {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
/**
|
||||
* @param array<int, int> $indexes
|
||||
*/
|
||||
protected function applyDnsStatuses(array $indexes, Server $server): void
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($indexes as $index) {
|
||||
$entries[(string) $index] = $this->domainRows[$index]['url'];
|
||||
}
|
||||
|
||||
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
|
||||
|
||||
foreach ($results as $index => $result) {
|
||||
$index = (int) $index;
|
||||
$this->domainRows[$index]['dns_status'] = $result['status'];
|
||||
$this->domainRows[$index]['dns_message'] = $result['message'];
|
||||
|
||||
// Keep suggested-row copy short after DNS checks (no role badge).
|
||||
if ($this->domainRows[$index]['is_suggested'] ?? false) {
|
||||
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
|
||||
$serviceName = $this->domainRows[$index]['service'] ?? null;
|
||||
$meta = $this->suggestedDomainMeta(
|
||||
$isWww,
|
||||
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
|
||||
);
|
||||
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
|
||||
$this->domainRows[$index]['suggestion_label'] = null;
|
||||
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
|
||||
}
|
||||
|
||||
// Keep suggested-row copy short after DNS checks (no role badge).
|
||||
if ($this->domainRows[$index]['is_suggested'] ?? false) {
|
||||
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
|
||||
$serviceName = $this->domainRows[$index]['service'] ?? null;
|
||||
$meta = $this->suggestedDomainMeta(
|
||||
$isWww,
|
||||
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
|
||||
);
|
||||
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
|
||||
$this->domainRows[$index]['suggestion_label'] = null;
|
||||
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
|
||||
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
|
||||
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -647,11 +696,34 @@ class Domains extends Component
|
||||
'message' => (string) ($row['dns_message'] ?? ''),
|
||||
'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
|
||||
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
|
||||
'check_id' => $row['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$statuses): void {
|
||||
$application = Application::query()->lockForUpdate()->findOrFail($this->application->id);
|
||||
$storedStatuses = $application->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($statuses as $key => $status) {
|
||||
$localCheckId = $status['check_id'] ?? null;
|
||||
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
|
||||
|
||||
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$application->save();
|
||||
});
|
||||
|
||||
$this->application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
protected function pruneDomainDnsStatusesToCurrentDomains(): void
|
||||
@@ -804,16 +876,6 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage($newUrls);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->addDomainDnsFailed = true;
|
||||
$this->addDomainDnsMessage = $dnsFailure;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
|
||||
$this->pendingAction = 'add';
|
||||
if (! $this->saveDomainList($merged, $this->newDomainService)) {
|
||||
@@ -825,14 +887,110 @@ class Domains extends Component
|
||||
$serviceForCheck = $this->newDomainService;
|
||||
$this->resetAddDomainForm();
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('success', 'Domain added.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck);
|
||||
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
$dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
|
||||
->map(fn (string $url, string $statusKey) => [
|
||||
'status_key' => $statusKey,
|
||||
'url' => $url,
|
||||
'check_id' => new_public_id(),
|
||||
]);
|
||||
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
|
||||
}
|
||||
$this->persistDomainDnsStatuses();
|
||||
|
||||
$failedDnsChecks = 0;
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
try {
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->application,
|
||||
$dnsCheck['status_key'],
|
||||
$dnsCheck['url'],
|
||||
$this->application->destination?->server,
|
||||
$this->serverIp,
|
||||
$dnsCheck['check_id'],
|
||||
$this->application->additional_servers->count() > 0,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$failedDnsChecks++;
|
||||
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedDnsChecks > 0) {
|
||||
$this->persistDomainDnsStatuses();
|
||||
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
|
||||
}
|
||||
|
||||
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
|
||||
? 'Domain added.'
|
||||
: 'Domain added. DNS check started.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void
|
||||
{
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($service !== null && ($row['service'] ?? null) !== $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void
|
||||
{
|
||||
$this->markUrlsAsChecking($urls, $service, $checkId);
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($service !== null && ($row['service'] ?? null) !== $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function dnsEntriesForUrls(array $urls, ?string $service = null): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
$entries[$this->domainDnsStatusKey($url, $service)] = $url;
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a first-time DNS check for newly added/updated domain URLs and persist results.
|
||||
*
|
||||
@@ -875,7 +1033,11 @@ class Domains extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $url, $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistDomainDnsStatuses();
|
||||
@@ -909,15 +1071,11 @@ class Domains extends Component
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = $this->dnsTargetLabel() ?? $server->ip;
|
||||
$results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
|
||||
|
||||
foreach ($urls as $url) {
|
||||
try {
|
||||
if (! validateDNSEntry($url, $server)) {
|
||||
return dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return 'Could not validate DNS for this domain.';
|
||||
foreach ($results as $result) {
|
||||
if ($result['status'] === 'failed') {
|
||||
return $result['message'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Server;
|
||||
@@ -131,6 +133,39 @@ class Domains extends Component
|
||||
$this->loadDomainState();
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
{
|
||||
$this->authorize('view', $this->service);
|
||||
|
||||
$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']
|
||||
&& (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']);
|
||||
|
||||
if (! is_array($row) || $row['dns_status'] === 'checking') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
|
||||
}
|
||||
}
|
||||
|
||||
protected 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}."),
|
||||
};
|
||||
}
|
||||
|
||||
public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void
|
||||
{
|
||||
$application = $this->service->applications()->findOrFail($serviceApplicationId);
|
||||
@@ -282,6 +317,7 @@ class Domains extends Component
|
||||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
@@ -298,6 +334,7 @@ class Domains extends Component
|
||||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
'checked_at' => null,
|
||||
'check_id' => null,
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
@@ -404,6 +441,8 @@ class Domains extends Component
|
||||
$server = $this->service->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled || ! $server;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
@@ -415,7 +454,11 @@ class Domains extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $row['url'], $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
@@ -443,33 +486,37 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server);
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, string $url, Server $server): void
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
{
|
||||
$target = $this->dnsTargetLabel();
|
||||
$this->applyDnsStatuses([$index], $server);
|
||||
}
|
||||
|
||||
try {
|
||||
$isValid = validateDNSEntry($url, $server);
|
||||
if ($isValid) {
|
||||
$this->domainRows[$index]['dns_status'] = 'ok';
|
||||
$this->domainRows[$index]['dns_message'] = $target
|
||||
? "DNS points to {$target} (or Cloudflare)."
|
||||
: 'DNS looks correct.';
|
||||
} else {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
|
||||
/**
|
||||
* @param array<int, int> $indexes
|
||||
*/
|
||||
protected function applyDnsStatuses(array $indexes, Server $server): void
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($indexes as $index) {
|
||||
$entries[(string) $index] = $this->domainRows[$index]['url'];
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
$this->decorateSuggestedDomainAfterDnsCheck($index);
|
||||
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
|
||||
|
||||
foreach ($results as $index => $result) {
|
||||
$index = (int) $index;
|
||||
$this->domainRows[$index]['dns_status'] = $result['status'];
|
||||
$this->domainRows[$index]['dns_message'] = $result['message'];
|
||||
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
|
||||
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->decorateSuggestedDomainAfterDnsCheck($index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -516,6 +563,7 @@ class Domains extends Component
|
||||
'message' => (string) ($row['dns_message'] ?? ''),
|
||||
'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
|
||||
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
|
||||
'check_id' => $row['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -528,8 +576,30 @@ class Domains extends Component
|
||||
->all();
|
||||
$statuses = array_intersect_key($statuses, array_flip($currentUrls));
|
||||
|
||||
DB::transaction(function () use ($app, &$statuses): void {
|
||||
$application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id);
|
||||
$storedStatuses = $application->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($statuses as $key => $status) {
|
||||
$localCheckId = $status['check_id'] ?? null;
|
||||
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
|
||||
|
||||
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$application->save();
|
||||
});
|
||||
|
||||
$app->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$app->save();
|
||||
}
|
||||
|
||||
$this->service->load('applications');
|
||||
@@ -928,16 +998,6 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->forceSaveDns && $this->shouldValidateDns()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage($newUrls);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->addDomainDnsFailed = true;
|
||||
$this->addDomainDnsMessage = $dnsFailure;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
|
||||
$this->pendingAction = 'add';
|
||||
|
||||
@@ -955,14 +1015,93 @@ class Domains extends Component
|
||||
$this->forceRemovePort = false;
|
||||
$this->pendingAction = null;
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('success', 'Domain added.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id);
|
||||
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
$serviceApplicationId = (int) $app->id;
|
||||
$dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
|
||||
'url' => $url,
|
||||
'check_id' => new_public_id(),
|
||||
]);
|
||||
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
|
||||
}
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
|
||||
$failedDnsChecks = 0;
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
try {
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$app,
|
||||
$dnsCheck['url'],
|
||||
$dnsCheck['url'],
|
||||
$this->service->server,
|
||||
$this->serverIp,
|
||||
$dnsCheck['check_id'],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$failedDnsChecks++;
|
||||
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedDnsChecks > 0) {
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
|
||||
}
|
||||
|
||||
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
|
||||
? 'Domain added.'
|
||||
: 'Domain added. DNS check started.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void
|
||||
{
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void
|
||||
{
|
||||
$this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId);
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
public function startEdit(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) {
|
||||
@@ -1309,7 +1448,11 @@ class Domains extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $url, $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
@@ -1330,15 +1473,11 @@ class Domains extends Component
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = $this->dnsTargetLabel() ?? $server->ip;
|
||||
$results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
|
||||
|
||||
foreach ($urls as $url) {
|
||||
try {
|
||||
if (! validateDNSEntry($url, $server)) {
|
||||
return dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return 'Could not validate DNS for this domain.';
|
||||
foreach ($results as $result) {
|
||||
if ($result['status'] === 'failed') {
|
||||
return $result['message'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,8 @@ class Create extends Component
|
||||
$label = str($resource->name)->headline();
|
||||
$targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [
|
||||
'key' => 'volume:'.$volume->id,
|
||||
'type' => 'Volume · '.$label,
|
||||
'name' => $volume->name,
|
||||
'type' => $label,
|
||||
'name' => str($volume->name)->after($this->service->uuid.'_')->value(),
|
||||
]));
|
||||
$targets->push(...$resource->fileStorages()
|
||||
->where('is_directory', true)
|
||||
@@ -109,8 +109,8 @@ class Create extends Component
|
||||
->get()
|
||||
->map(fn (LocalFileVolume $directory): array => [
|
||||
'key' => 'directory:'.$directory->id,
|
||||
'type' => 'Directory · '.$label,
|
||||
'name' => $directory->fs_path,
|
||||
'type' => $label,
|
||||
'name' => $directory->fs_path.' (directory)',
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ class Index extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}");
|
||||
$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");
|
||||
$buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile .";
|
||||
|
||||
$activity = remote_process(
|
||||
|
||||
@@ -47,7 +47,7 @@ class Index extends Component
|
||||
return [
|
||||
'name' => data_get($container, 'Names'),
|
||||
'connection_name' => data_get($container, 'Names'),
|
||||
'uuid' => data_get($container, 'Names'),
|
||||
'uuid' => $server->uuid.':'.data_get($container, 'Names'),
|
||||
'status' => data_get_str($container, 'State')->lower(),
|
||||
'server' => $server,
|
||||
'server_uuid' => $server->uuid,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use RuntimeException;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class ApplicationPreview extends BaseModel
|
||||
@@ -28,9 +29,9 @@ class ApplicationPreview extends BaseModel
|
||||
'pull_request_id' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::forceDeleting(function ($preview) {
|
||||
static::forceDeleting(function (ApplicationPreview $preview): void {
|
||||
$server = $preview->application->destination->server;
|
||||
$application = $preview->application;
|
||||
|
||||
@@ -57,10 +58,19 @@ class ApplicationPreview extends BaseModel
|
||||
});
|
||||
} else {
|
||||
// Regular application volume cleanup
|
||||
$persistentStorages = $preview->persistentStorages()->get() ?? collect();
|
||||
if ($persistentStorages->count() > 0) {
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
$persistentStorages = $application->persistentStorages()
|
||||
->get()
|
||||
->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path)
|
||||
&& $storage->is_preview_suffix_enabled);
|
||||
|
||||
foreach ($persistentStorages as $storage) {
|
||||
$volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id);
|
||||
try {
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server);
|
||||
} catch (RuntimeException $exception) {
|
||||
if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ namespace App\Support;
|
||||
|
||||
class DomainUrlParts
|
||||
{
|
||||
public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string
|
||||
public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string
|
||||
{
|
||||
$scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https';
|
||||
$host = trim($host);
|
||||
$port = trim($port);
|
||||
$port = trim((string) $port);
|
||||
$path = trim($path);
|
||||
|
||||
if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) {
|
||||
|
||||
@@ -4152,11 +4152,12 @@ function coolifyHelperImage(): string
|
||||
|
||||
function getHelperVersion(): string
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
if (isDev()) {
|
||||
$devHelperVersion = InstanceSettings::query()->whereKey(0)->value('dev_helper_version');
|
||||
|
||||
// In development mode, use the dev_helper_version if set, otherwise fallback to config
|
||||
if (isDev() && ! empty($settings->dev_helper_version)) {
|
||||
return $settings->dev_helper_version;
|
||||
if (! empty($devHelperVersion)) {
|
||||
return $devHelperVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return config('constants.coolify.helper_version');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => env('COOLIFY_VERSION') ?: '4.3.10',
|
||||
'version' => env('COOLIFY_VERSION') ?: '4.3.11',
|
||||
'helper_version' => '1.0.15',
|
||||
'realtime_version' => '1.0.17',
|
||||
'railpack_version' => '0.23.0',
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# https://hub.docker.com/_/alpine
|
||||
ARG BASE_IMAGE=alpine:3.21
|
||||
# https://download.docker.com/linux/static/stable/
|
||||
ARG DOCKER_VERSION=28.0.0
|
||||
ARG DOCKER_VERSION=29.7.2
|
||||
# https://github.com/docker/compose/releases
|
||||
ARG DOCKER_COMPOSE_VERSION=2.38.2
|
||||
ARG DOCKER_COMPOSE_VERSION=5.5.0
|
||||
# https://github.com/docker/buildx/releases
|
||||
ARG DOCKER_BUILDX_VERSION=0.25.0
|
||||
ARG DOCKER_BUILDX_VERSION=0.36.1
|
||||
# https://github.com/buildpacks/pack/releases
|
||||
ARG PACK_VERSION=0.38.2
|
||||
# https://github.com/railwayapp/nixpacks/releases
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.3.10"
|
||||
"version": "4.3.11"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.4-rc.1"
|
||||
|
||||
@@ -334,12 +334,15 @@
|
||||
step++;
|
||||
} else {
|
||||
submitting = true;
|
||||
submitForm().then((result) => {
|
||||
submitting = false;
|
||||
modalOpen = false;
|
||||
resetModal();
|
||||
}).catch(() => {
|
||||
submitting = false;
|
||||
modalOpen = false;
|
||||
$nextTick(() => {
|
||||
submitForm().then((result) => {
|
||||
submitting = false;
|
||||
resetModal();
|
||||
}).catch(() => {
|
||||
submitting = false;
|
||||
modalOpen = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
">
|
||||
@@ -388,17 +391,21 @@
|
||||
$wire.dispatch(dispatchEventType, dispatchEventMessage);
|
||||
}
|
||||
submitting = true;
|
||||
submitForm().then((result) => {
|
||||
submitting = false;
|
||||
if (result === true) {
|
||||
modalOpen = false;
|
||||
resetModal();
|
||||
} else {
|
||||
passwordError = result;
|
||||
password = '';
|
||||
}
|
||||
}).catch(() => {
|
||||
submitting = false;
|
||||
modalOpen = false;
|
||||
$nextTick(() => {
|
||||
submitForm().then((result) => {
|
||||
submitting = false;
|
||||
if (result === true) {
|
||||
resetModal();
|
||||
} else {
|
||||
modalOpen = true;
|
||||
passwordError = result;
|
||||
password = '';
|
||||
}
|
||||
}).catch(() => {
|
||||
submitting = false;
|
||||
modalOpen = true;
|
||||
});
|
||||
});
|
||||
">
|
||||
<x-loading-on-button x-show="submitting" x-cloak />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
$configuredCount = collect($domainRows)->where('is_suggested', false)->count();
|
||||
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
|
||||
$hasRows = count($domainRows) > 0;
|
||||
$hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking');
|
||||
$composeDomainGroups = collect($domainRows)
|
||||
->groupBy(fn ($row) => $row['service'] ?? '__unknown')
|
||||
->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false)));
|
||||
@@ -36,6 +37,9 @@
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain()"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
@if ($hasDnsChecksInProgress)
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
<x-application.settings-section id="domains-section" title="Domains">
|
||||
@can('update', $application)
|
||||
<x-slot:actions>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
'ok' => 'DNS OK',
|
||||
'failed' => 'DNS mismatch',
|
||||
'skipped' => 'DNS skipped',
|
||||
'checking' => 'Checking DNS...',
|
||||
'pending' => 'DNS pending',
|
||||
default => 'DNS unknown',
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
$configuredCount = collect($domainRows)->where('is_suggested', false)->count();
|
||||
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
|
||||
$hasRows = count($domainRows) > 0;
|
||||
$hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking');
|
||||
$serviceAppCount = count($serviceApps);
|
||||
$domainGroups = collect($domainRows)
|
||||
->groupBy('service_application_id')
|
||||
@@ -37,6 +38,9 @@
|
||||
}"
|
||||
@open-edit-domain.window="openEditDomain()"
|
||||
@edit-domain-saved.window="closeEditDomain()">
|
||||
@if ($hasDnsChecksInProgress)
|
||||
<div class="hidden" wire:poll.2000ms="pollDnsChecks" aria-hidden="true"></div>
|
||||
@endif
|
||||
<x-application.settings-section id="service-domains-section" title="Domains">
|
||||
@can('update', $service)
|
||||
<x-slot:actions>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
'ok' => 'DNS OK',
|
||||
'failed' => 'DNS mismatch',
|
||||
'skipped' => 'DNS skipped',
|
||||
'checking' => 'Checking DNS...',
|
||||
'pending' => 'DNS pending',
|
||||
default => 'DNS unknown',
|
||||
};
|
||||
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
REPOSITORY=${HELPER_IMAGE_REPOSITORY:-docker.io/coollabsio/coolify-helper}
|
||||
DEFAULT_TAG="dev-$(git -C "$ROOT_DIR" rev-parse --short HEAD)"
|
||||
COOLIFY_CONTAINER=${COOLIFY_CONTAINER:-coolify}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 <command> [tag]
|
||||
|
||||
Commands:
|
||||
build [tag] Build the local helper image
|
||||
use [tag] Configure local Coolify to use an existing helper image
|
||||
verify [tag] Verify tools and run a Docker Compose socket smoke test
|
||||
deploy [tag] Deploy seeded Dockerfile, Compose, and Nixpacks applications
|
||||
reset Clear the local helper version override
|
||||
test [tag] Build, select, verify, and run real seeded deployments
|
||||
|
||||
Default tag: $DEFAULT_TAG
|
||||
EOF
|
||||
}
|
||||
|
||||
require_local_coolify() {
|
||||
local is_dev
|
||||
|
||||
if ! docker inspect "$COOLIFY_CONTAINER" >/dev/null 2>&1; then
|
||||
echo "Coolify container '$COOLIFY_CONTAINER' is not running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
is_dev=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo isDev() ? "yes" : "no";' 2>/dev/null | tail -1)
|
||||
if [[ $is_dev != yes ]]; then
|
||||
echo "The running Coolify instance is not in development mode." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_tag() {
|
||||
local tag=$1
|
||||
|
||||
if [[ ! $tag =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
|
||||
echo "Invalid Docker tag: $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
image_for() {
|
||||
echo "${REPOSITORY}:$1"
|
||||
}
|
||||
|
||||
build_helper() {
|
||||
local tag=$1
|
||||
local image
|
||||
image=$(image_for "$tag")
|
||||
|
||||
echo "Building $image"
|
||||
docker build --progress=plain -f "$ROOT_DIR/docker/coolify-helper/Dockerfile" -t "$image" "$ROOT_DIR"
|
||||
}
|
||||
|
||||
use_helper() {
|
||||
local tag=$1
|
||||
local image
|
||||
image=$(image_for "$tag")
|
||||
|
||||
require_local_coolify
|
||||
docker image inspect "$image" >/dev/null
|
||||
docker exec -e DEV_HELPER_VERSION="$tag" "$COOLIFY_CONTAINER" php artisan tinker --execute '
|
||||
App\Models\InstanceSettings::findOrFail(0)->update([
|
||||
"dev_helper_version" => getenv("DEV_HELPER_VERSION"),
|
||||
]);
|
||||
' >/dev/null
|
||||
|
||||
local selected
|
||||
selected=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo getHelperVersion();' 2>/dev/null | tail -1)
|
||||
if [[ $selected != "$tag" ]]; then
|
||||
echo "Coolify selected helper '$selected' instead of '$tag'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Coolify now uses $image"
|
||||
}
|
||||
|
||||
verify_helper() {
|
||||
local tag=$1
|
||||
local image project compose
|
||||
image=$(image_for "$tag")
|
||||
project="coolify-helper-${tag//[^A-Za-z0-9_-]/-}-smoke"
|
||||
compose=$'services:\n app:\n image: alpine:3.21\n command: ["sh", "-c", "sleep 30"]\n'
|
||||
|
||||
docker image inspect "$image" >/dev/null
|
||||
|
||||
docker run --rm "$image" docker --version
|
||||
docker run --rm "$image" docker compose version
|
||||
docker run --rm "$image" docker buildx version
|
||||
docker run --rm "$image" pack version
|
||||
docker run --rm "$image" nixpacks --version
|
||||
docker run --rm "$image" railpack --version
|
||||
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \
|
||||
docker version --format 'client={{.Client.Version}} server={{.Server.Version}} api={{.Client.APIVersion}}/{{.Server.APIVersion}}'
|
||||
|
||||
cleanup_compose() {
|
||||
printf '%s' "$compose" | docker run --rm -i \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock "$image" \
|
||||
docker compose -p "$project" -f - down --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup_compose RETURN
|
||||
|
||||
printf '%s' "$compose" | docker run --rm -i \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock "$image" \
|
||||
docker compose -p "$project" -f - up -d --wait
|
||||
printf '%s' "$compose" | docker run --rm -i \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock "$image" \
|
||||
docker compose -p "$project" -f - ps --format json
|
||||
cleanup_compose
|
||||
trap - RETURN
|
||||
|
||||
if [[ -n $(docker ps -aq --filter "label=com.docker.compose.project=$project") ]]; then
|
||||
echo "Compose smoke-test resources were not removed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Helper verification passed for $image"
|
||||
}
|
||||
|
||||
queue_deployment() {
|
||||
local application_uuid=$1
|
||||
|
||||
docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute '
|
||||
$application = App\Models\Application::query()
|
||||
->where("uuid", getenv("APPLICATION_UUID"))
|
||||
->firstOrFail();
|
||||
$deploymentUuid = (string) Illuminate\Support\Str::uuid();
|
||||
queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deploymentUuid,
|
||||
force_rebuild: true,
|
||||
no_questions_asked: true,
|
||||
);
|
||||
echo "DEPLOYMENT_UUID={$deploymentUuid}";
|
||||
' 2>/dev/null | sed -n 's/^DEPLOYMENT_UUID=//p' | tail -1
|
||||
}
|
||||
|
||||
wait_for_deployment() {
|
||||
local deployment_uuid=$1
|
||||
local expected_image=$2
|
||||
local status
|
||||
|
||||
for _ in $(seq 1 150); do
|
||||
status=$(docker exec -e DEPLOYMENT_UUID="$deployment_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute '
|
||||
$deployment = App\Models\ApplicationDeploymentQueue::query()
|
||||
->where("deployment_uuid", getenv("DEPLOYMENT_UUID"))
|
||||
->first();
|
||||
echo $deployment?->status ?? "missing";
|
||||
' 2>/dev/null | tail -1)
|
||||
|
||||
case "$status" in
|
||||
finished)
|
||||
break
|
||||
;;
|
||||
failed|cancelled|missing)
|
||||
echo "Deployment $deployment_uuid ended with status: $status" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
sleep 4
|
||||
done
|
||||
|
||||
if [[ $status != finished ]]; then
|
||||
echo "Deployment $deployment_uuid timed out with status: $status" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local used_expected_helper
|
||||
used_expected_helper=$(docker exec \
|
||||
-e DEPLOYMENT_UUID="$deployment_uuid" \
|
||||
-e EXPECTED_HELPER_IMAGE="$expected_image" \
|
||||
"$COOLIFY_CONTAINER" php artisan tinker --execute '
|
||||
$deployment = App\Models\ApplicationDeploymentQueue::query()
|
||||
->where("deployment_uuid", getenv("DEPLOYMENT_UUID"))
|
||||
->firstOrFail();
|
||||
$logs = collect(json_decode($deployment->logs, true))
|
||||
->pluck("output")
|
||||
->implode("\n");
|
||||
echo str_contains(
|
||||
$logs,
|
||||
"Preparing container with helper image: ".getenv("EXPECTED_HELPER_IMAGE"),
|
||||
) ? "yes" : "no";
|
||||
' 2>/dev/null | tail -1)
|
||||
|
||||
if [[ $used_expected_helper != yes ]]; then
|
||||
echo "Deployment $deployment_uuid did not use $expected_image." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
deploy_examples() {
|
||||
local tag=$1
|
||||
local image application_uuid deployment_uuid fqdn
|
||||
image=$(image_for "$tag")
|
||||
|
||||
require_local_coolify
|
||||
use_helper "$tag"
|
||||
|
||||
for application_uuid in dockerfile docker-compose nodejs; do
|
||||
echo "Deploying seeded application: $application_uuid"
|
||||
deployment_uuid=$(queue_deployment "$application_uuid")
|
||||
if [[ -z $deployment_uuid ]]; then
|
||||
echo "Could not queue $application_uuid." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wait_for_deployment "$deployment_uuid" "$image"
|
||||
|
||||
fqdn=$(docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute '
|
||||
echo App\Models\Application::query()
|
||||
->where("uuid", getenv("APPLICATION_UUID"))
|
||||
->value("fqdn") ?? "";
|
||||
' 2>/dev/null | tail -1)
|
||||
|
||||
if [[ -n $fqdn ]]; then
|
||||
curl --fail --silent --show-error --output /dev/null "$fqdn"
|
||||
fi
|
||||
|
||||
echo "Deployment passed: $application_uuid ($deployment_uuid)"
|
||||
done
|
||||
}
|
||||
|
||||
reset_helper() {
|
||||
require_local_coolify
|
||||
docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute '
|
||||
App\Models\InstanceSettings::findOrFail(0)->update([
|
||||
"dev_helper_version" => null,
|
||||
]);
|
||||
' >/dev/null
|
||||
echo "Development helper override cleared."
|
||||
}
|
||||
|
||||
command=${1:-help}
|
||||
tag=${2:-$DEFAULT_TAG}
|
||||
|
||||
case "$command" in
|
||||
build)
|
||||
validate_tag "$tag"
|
||||
build_helper "$tag"
|
||||
;;
|
||||
use)
|
||||
validate_tag "$tag"
|
||||
use_helper "$tag"
|
||||
;;
|
||||
verify)
|
||||
validate_tag "$tag"
|
||||
verify_helper "$tag"
|
||||
;;
|
||||
deploy)
|
||||
validate_tag "$tag"
|
||||
deploy_examples "$tag"
|
||||
;;
|
||||
reset)
|
||||
reset_helper
|
||||
;;
|
||||
test)
|
||||
validate_tag "$tag"
|
||||
build_helper "$tag"
|
||||
use_helper "$tag"
|
||||
verify_helper "$tag"
|
||||
deploy_examples "$tag"
|
||||
;;
|
||||
help|-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -254,4 +254,58 @@ describe('other application creation endpoints use_build_secrets', function () {
|
||||
|
||||
expect($application->settings->use_build_secrets)->toBeTrue();
|
||||
});
|
||||
|
||||
test('creates an application from a system-wide GitHub App owned by another team', function () {
|
||||
$ownerTeam = Team::factory()->create();
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'System-wide GitHub App Key',
|
||||
'private_key' => buildSecretsGithubPrivateKey(),
|
||||
'team_id' => $ownerTeam->id,
|
||||
]);
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'System-wide GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'app_id' => 54321,
|
||||
'installation_id' => 9876,
|
||||
'client_id' => 'system-wide-client-id',
|
||||
'client_secret' => 'system-wide-client-secret',
|
||||
'webhook_secret' => 'system-wide-webhook-secret',
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $ownerTeam->id,
|
||||
'is_system_wide' => true,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/9876/access_tokens' => Http::response([
|
||||
'token' => 'github-installation-token',
|
||||
], 201),
|
||||
'https://api.github.com/repos/coolify/system-wide-test' => Http::response([
|
||||
'id' => 654321,
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/private-github-app', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'git_repository' => 'coolify/system-wide-test',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'autogenerate_domain' => false,
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($application->source_id)->toBe($githubApp->id)
|
||||
->and($application->environment_id)->toBe($this->environment->id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Project\Application\Domains;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
@@ -12,6 +13,7 @@ use App\Models\User;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -248,7 +250,7 @@ it('adds a domain to the application', function () {
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('addDomainDnsFailed', false)
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('success', 'Domain added. DNS check started.')
|
||||
->assertDispatched('close-modal');
|
||||
|
||||
$this->application->refresh();
|
||||
@@ -290,7 +292,9 @@ it('adds multiple domains without replacing existing ones', function () {
|
||||
->toContain('https://api.example.com');
|
||||
});
|
||||
|
||||
it('blocks adding a domain with bad dns until the user continues', function () {
|
||||
it('saves a domain before checking dns in a separate request', function () {
|
||||
Queue::fake();
|
||||
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
$settings->save();
|
||||
@@ -298,15 +302,8 @@ it('blocks adding a domain with bad dns until the user continues', function () {
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->call('addDomain')
|
||||
->assertSet('addDomainDnsFailed', true)
|
||||
->assertSee('DNS is not pointing to the right IP')
|
||||
->assertSee('Are you sure you want to add it anyway');
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->fqdn)->toBeNull();
|
||||
|
||||
$component->call('confirmAddDomainDespiteDns')
|
||||
->assertSet('addDomainDnsFailed', false)
|
||||
->assertSet('domainRows.0.dns_status', 'checking')
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('close-modal');
|
||||
|
||||
@@ -315,17 +312,24 @@ it('blocks adding a domain with bad dns until the user continues', function () {
|
||||
'https://this-domain-should-not-resolve-for-coolify-tests.invalid',
|
||||
'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid',
|
||||
]);
|
||||
|
||||
expect($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null)
|
||||
->toBe('checking');
|
||||
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, 2);
|
||||
|
||||
$jobs = Queue::pushed(CheckDomainDnsJob::class);
|
||||
|
||||
expect($jobs->pluck('statusKey')->all())->toEqualCanonicalizing([
|
||||
'https://this-domain-should-not-resolve-for-coolify-tests.invalid',
|
||||
'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid',
|
||||
])->and($jobs->pluck('checkId')->unique())->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('resets the dns gate when the domain input changes', function () {
|
||||
$settings = InstanceSettings::get();
|
||||
$settings->is_dns_validation_enabled = true;
|
||||
$settings->save();
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid')
|
||||
->call('addDomain')
|
||||
->assertSet('addDomainDnsFailed', true)
|
||||
->set('addDomainDnsFailed', true)
|
||||
->set('forceSaveDns', true)
|
||||
->set('newDomain', 'https://another.example.com')
|
||||
->assertSet('addDomainDnsFailed', false)
|
||||
->assertSet('forceSaveDns', false);
|
||||
@@ -727,6 +731,98 @@ it('persists dns status after checking a domain', function () {
|
||||
->and($entry['checked_at'] ?? null)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('polls a queued dns check and notifies about a mismatch', function () {
|
||||
$domain = 'https://app.example.com';
|
||||
$this->application->update([
|
||||
'fqdn' => $domain,
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'checking',
|
||||
'message' => 'Checking DNS...',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSee('Checking DNS...')
|
||||
->assertSee('wire:poll.2000ms="pollDnsChecks"', false);
|
||||
|
||||
$this->application->update([
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'failed',
|
||||
'message' => 'Required DNS record type A pointing to 203.0.113.10',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component->call('pollDnsChecks')
|
||||
->assertSet('domainRows.0.dns_status', 'failed')
|
||||
->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record.');
|
||||
});
|
||||
|
||||
it('does not overwrite a completed queued dns result with stale checking state', function () {
|
||||
$domain = 'https://app.example.com';
|
||||
$this->application->update([
|
||||
'fqdn' => $domain,
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'checking',
|
||||
'message' => 'Checking DNS...',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
|
||||
$this->application->update([
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'ok',
|
||||
'message' => 'DNS looks correct.',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses');
|
||||
$method->invoke($component->instance());
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses[$domain]['status'])->toBe('ok');
|
||||
});
|
||||
|
||||
it('does not overwrite a newer queued dns check with stale completed component state', function () {
|
||||
$domain = 'https://app.example.com';
|
||||
$status = [
|
||||
'status' => 'ok',
|
||||
'message' => 'DNS looks correct.',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => null,
|
||||
'check_id' => null,
|
||||
];
|
||||
$this->application->update([
|
||||
'fqdn' => $domain,
|
||||
'domain_dns_statuses' => [$domain => $status],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
|
||||
|
||||
$status['check_id'] = 'newer-check';
|
||||
$this->application->update(['domain_dns_statuses' => [$domain => $status]]);
|
||||
|
||||
$method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses');
|
||||
$method->invoke($component->instance());
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses[$domain]['check_id'])->toBe('newer-check');
|
||||
});
|
||||
|
||||
it('resolves hostname server addresses to a real ip for dns messages', function () {
|
||||
$this->server->update(['ip' => 'localhost']);
|
||||
$this->application->update([
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'user' => 'deploy',
|
||||
]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = $project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'build_pack' => 'dockerfile',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
$this->preview = ApplicationPreview::create([
|
||||
'uuid' => 'preview-volume-cleanup-test',
|
||||
'application_id' => $this->application->id,
|
||||
'pull_request_id' => 42,
|
||||
'pull_request_html_url' => 'https://github.com/example/repository/pull/42',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes only named volumes that have the preview suffix enabled', function () {
|
||||
$this->application->persistentStorages()->create([
|
||||
'name' => 'app-data',
|
||||
'mount_path' => '/data',
|
||||
'host_path' => null,
|
||||
'is_preview_suffix_enabled' => true,
|
||||
]);
|
||||
$this->application->persistentStorages()->create([
|
||||
'name' => 'shared-cache',
|
||||
'mount_path' => '/cache',
|
||||
'host_path' => null,
|
||||
'is_preview_suffix_enabled' => false,
|
||||
]);
|
||||
$this->application->persistentStorages()->create([
|
||||
'name' => 'seed-data',
|
||||
'mount_path' => '/seed',
|
||||
'host_path' => '/srv/seed',
|
||||
'is_preview_suffix_enabled' => true,
|
||||
]);
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
$this->preview->forceDelete();
|
||||
|
||||
Process::assertRanTimes(fn ($process) => str_contains($process->command, 'docker volume rm'), 1);
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker volume rm -f 'app-data-pr-42'"));
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'shared-cache'));
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'seed-data'));
|
||||
});
|
||||
|
||||
it('reports Docker volume removal failures and keeps the preview record', function () {
|
||||
$this->application->persistentStorages()->create([
|
||||
'name' => 'app-data',
|
||||
'mount_path' => '/data',
|
||||
'host_path' => null,
|
||||
'is_preview_suffix_enabled' => true,
|
||||
]);
|
||||
Process::fake(['*' => Process::result(errorOutput: 'volume is in use', exitCode: 1)]);
|
||||
|
||||
expect(fn () => $this->preview->forceDelete())
|
||||
->toThrow(RuntimeException::class, 'volume is in use');
|
||||
|
||||
expect(ApplicationPreview::find($this->preview->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('continues removing preview volumes when an earlier volume is already absent', function () {
|
||||
$this->application->persistentStorages()->create([
|
||||
'name' => 'already-removed',
|
||||
'mount_path' => '/removed',
|
||||
'host_path' => null,
|
||||
'is_preview_suffix_enabled' => true,
|
||||
]);
|
||||
$this->application->persistentStorages()->create([
|
||||
'name' => 'app-data',
|
||||
'mount_path' => '/data',
|
||||
'host_path' => null,
|
||||
'is_preview_suffix_enabled' => true,
|
||||
]);
|
||||
Process::fake(function ($process) {
|
||||
if (str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")) {
|
||||
return Process::result(
|
||||
errorOutput: 'Error response from daemon: volume already-removed-pr-42 not found',
|
||||
exitCode: 1,
|
||||
);
|
||||
}
|
||||
|
||||
return Process::result(output: 'app-data-pr-42');
|
||||
});
|
||||
|
||||
$this->preview->forceDelete();
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'"));
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'"));
|
||||
expect(ApplicationPreview::find($this->preview->id))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(fn () => CheckDomainDns::clearFake());
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'is_dns_validation_enabled' => false,
|
||||
]));
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => 1,
|
||||
'destination_type' => 'App\\Models\\StandaloneDocker',
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'domain_dns_statuses' => [
|
||||
'https://app.example.com' => [
|
||||
'status' => 'checking',
|
||||
'message' => 'Checking DNS...',
|
||||
'expected_ip' => null,
|
||||
'checked_at' => null,
|
||||
'check_id' => 'test-check',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists a skipped result when dns validation is disabled', function () {
|
||||
(new CheckDomainDnsJob(
|
||||
$this->application,
|
||||
'https://app.example.com',
|
||||
'https://app.example.com',
|
||||
null,
|
||||
null,
|
||||
'test-check',
|
||||
))->handle();
|
||||
|
||||
$status = $this->application->fresh()->domain_dns_statuses['https://app.example.com'];
|
||||
|
||||
expect($status['status'])->toBe('skipped')
|
||||
->and($status['message'])->toBe('DNS validation is disabled in instance settings.')
|
||||
->and($status['checked_at'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not restore a dns status removed before the job finishes', function () {
|
||||
$this->application->update(['domain_dns_statuses' => null]);
|
||||
|
||||
(new CheckDomainDnsJob(
|
||||
$this->application,
|
||||
'https://app.example.com',
|
||||
'https://app.example.com',
|
||||
null,
|
||||
null,
|
||||
'test-check',
|
||||
))->handle();
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses)->toBeNull();
|
||||
});
|
||||
|
||||
it('uses the shared dns action', function () {
|
||||
CheckDomainDns::shouldRun()
|
||||
->once()
|
||||
->andReturn([
|
||||
'https://app.example.com' => [
|
||||
'status' => 'ok',
|
||||
'message' => 'DNS looks correct.',
|
||||
'expected_ip' => null,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
|
||||
(new CheckDomainDnsJob(
|
||||
$this->application,
|
||||
'https://app.example.com',
|
||||
'https://app.example.com',
|
||||
null,
|
||||
null,
|
||||
'test-check',
|
||||
))->handle();
|
||||
|
||||
expect($this->application->fresh()->domain_dns_statuses['https://app.example.com']['status'])->toBe('ok');
|
||||
});
|
||||
|
||||
it('does not let an older job overwrite a newer check for the same domain', function () {
|
||||
$oldJob = new CheckDomainDnsJob(
|
||||
$this->application,
|
||||
'https://app.example.com',
|
||||
'https://app.example.com',
|
||||
null,
|
||||
null,
|
||||
'test-check',
|
||||
);
|
||||
|
||||
$statuses = $this->application->domain_dns_statuses;
|
||||
$statuses['https://app.example.com']['check_id'] = 'newer-check';
|
||||
$this->application->update(['domain_dns_statuses' => $statuses]);
|
||||
|
||||
$oldJob->handle();
|
||||
|
||||
$status = $this->application->fresh()->domain_dns_statuses['https://app.example.com'];
|
||||
|
||||
expect($status['status'])->toBe('checking')
|
||||
->and($status['check_id'])->toBe('newer-check');
|
||||
});
|
||||
@@ -88,3 +88,22 @@ test('buildHelperImage refuses previously stored invalid version', function () {
|
||||
->call('buildHelperImage')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
test('development helper version is read fresh for queue workers', function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update(['dev_helper_version' => 'first']);
|
||||
expect(getHelperVersion())->toBe('first');
|
||||
|
||||
InstanceSettings::query()->whereKey(0)->update(['dev_helper_version' => 'second']);
|
||||
|
||||
expect(getHelperVersion())->toBe('second');
|
||||
});
|
||||
|
||||
test('development helper build uses the configured helper repository', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Settings/Index.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");')
|
||||
->not->toContain('"ghcr.io/coollabsio/coolify-helper:{$version}"');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -9,6 +10,27 @@ use PurplePixie\PhpDns\DNSTypes;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns a skipped dns result when instance validation is disabled', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
['is_dns_validation_enabled' => false]
|
||||
));
|
||||
|
||||
$result = CheckDomainDns::run(
|
||||
['https://example.com' => 'https://example.com'],
|
||||
new Server(['ip' => '203.0.113.10']),
|
||||
'203.0.113.10',
|
||||
);
|
||||
|
||||
expect($result['https://example.com'])
|
||||
->toMatchArray([
|
||||
'status' => 'skipped',
|
||||
'message' => 'DNS validation is disabled in instance settings.',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
])
|
||||
->and($result['https://example.com']['checked_at'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('stops querying DNS servers after finding a matching IP', function (string $resolvedIp) {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
@@ -51,7 +73,40 @@ it('stops querying DNS servers after finding a matching IP', function (string $r
|
||||
|
||||
expect(validateDNSEntry('https://example.com', $server))->toBeTrue()
|
||||
->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1']);
|
||||
|
||||
$result = CheckDomainDns::run(['example' => 'https://example.com'], $server, $targetIp);
|
||||
|
||||
expect($result['example']['status'])->toBe('ok')
|
||||
->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1', '192.0.2.1']);
|
||||
})->with([
|
||||
'target server IP' => '203.0.113.10',
|
||||
'Cloudflare IP' => '104.16.0.1',
|
||||
]);
|
||||
|
||||
it('does not start another resolver query after the total dns budget is exhausted', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
[
|
||||
'is_dns_validation_enabled' => true,
|
||||
'custom_dns_servers' => '192.0.2.1,192.0.2.2',
|
||||
]
|
||||
));
|
||||
|
||||
$queryCount = new ArrayObject;
|
||||
app()->bind(DNSQuery::class, function () use ($queryCount) {
|
||||
$queryCount->append(true);
|
||||
|
||||
return new DNSQuery('192.0.2.1');
|
||||
});
|
||||
|
||||
$result = CheckDomainDns::run(
|
||||
['example' => 'https://example.com'],
|
||||
new Server(['ip' => '203.0.113.10']),
|
||||
'203.0.113.10',
|
||||
timeoutSeconds: 0,
|
||||
);
|
||||
|
||||
expect($result['example']['status'])->toBe('failed')
|
||||
->and($result['example']['message'])->toBe('Could not validate DNS for this domain.')
|
||||
->and($queryCount)->toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -7,3 +7,11 @@ test('confirmation modal closes before dispatching an event that can open anothe
|
||||
'/if \(dispatchEvent\) \{\s*modalOpen = false;\s*\$nextTick\(\(\) => \$wire\.dispatch\(dispatchEventType, dispatchEventMessage\)\);/s'
|
||||
);
|
||||
});
|
||||
|
||||
test('confirmation modal releases its scroll lock before submitting a destructive action', function () {
|
||||
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
|
||||
|
||||
expect($modal)
|
||||
->toMatch('/submitting = true;\s*modalOpen = false;\s*\$nextTick\(\(\) => \{\s*submitForm\(\)/s')
|
||||
->toMatch('/if \(result === true\) \{\s*resetModal\(\);/s');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Project\Service\Domains;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -11,6 +12,7 @@ use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -283,39 +285,38 @@ it('auto-adds missing non-www pair for a service application redirect', function
|
||||
it('adds only the entered domain when redirects allow both directions', function () {
|
||||
$this->webApp->update(['redirect' => 'both']);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set('newServiceApplicationId', $this->webApp->id)
|
||||
->set('newDomain', 'https://web.example.com')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('success');
|
||||
|
||||
$component->call('pollDnsChecks')
|
||||
->assertSee('DNS skipped');
|
||||
|
||||
expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
|
||||
});
|
||||
|
||||
it('adds a domain to a selected service application', function () {
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->set('newServiceApplicationId', $this->webApp->id)
|
||||
->set('newDomain', 'https://web.example.com')
|
||||
->call('addDomain')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success')
|
||||
->assertDispatched('success', 'Domain added. DNS check started.')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', 'https://web.example.com')['dns_status'] === 'checking')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://web.example.com'))
|
||||
->assertSee('https://web.example.com');
|
||||
|
||||
$this->webApp->refresh();
|
||||
expect($this->webApp->fqdn)->toBe('https://web.example.com');
|
||||
|
||||
$dnsStatuses = $this->webApp->domain_dns_statuses;
|
||||
expect($this->webApp->domain_dns_statuses['https://web.example.com']['status'] ?? null)->toBe('checking');
|
||||
|
||||
expect($dnsStatuses)
|
||||
->toHaveKey('https://web.example.com')
|
||||
->not->toHaveKey('https://www.web.example.com')
|
||||
->and($dnsStatuses['https://web.example.com']['status'])
|
||||
->toBe('skipped')
|
||||
->and($dnsStatuses['https://web.example.com']['checked_at'])
|
||||
->not->toBeNull();
|
||||
Queue::assertPushed(CheckDomainDnsJob::class, 1);
|
||||
});
|
||||
|
||||
it('adds a domain when the compose service has an empty environment section', function () {
|
||||
@@ -457,7 +458,7 @@ it('does not restore stale dns status when a removed service domain is re-added'
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->call('removeDomain', 0)
|
||||
->set('newServiceApplicationId', $this->apiApp->id)
|
||||
->set('newDomain', 'https://api.example.com')
|
||||
@@ -465,6 +466,8 @@ it('does not restore stale dns status when a removed service domain is re-added'
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
$component->call('pollDnsChecks');
|
||||
|
||||
$this->apiApp->refresh();
|
||||
|
||||
expect(explode(',', (string) $this->apiApp->fqdn))
|
||||
@@ -564,6 +567,39 @@ it('hides dns message text when service domain dns status is ok', function () {
|
||||
->assertDontSee('DNS points to 203.0.113.10');
|
||||
});
|
||||
|
||||
it('polls a queued service dns check and notifies about success', function () {
|
||||
$domain = 'https://api.example.com';
|
||||
$this->apiApp->update([
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'checking',
|
||||
'message' => 'Checking DNS...',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
|
||||
->assertSee('Checking DNS...')
|
||||
->assertSee('wire:poll.2000ms="pollDnsChecks"', false);
|
||||
|
||||
$this->apiApp->update([
|
||||
'domain_dns_statuses' => [
|
||||
$domain => [
|
||||
'status' => 'ok',
|
||||
'message' => 'DNS looks correct.',
|
||||
'expected_ip' => '203.0.113.10',
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$component->call('pollDnsChecks')
|
||||
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', $domain)['dns_status'] === 'ok')
|
||||
->assertDispatched('success', 'DNS is configured correctly for api.example.com.');
|
||||
});
|
||||
|
||||
it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) {
|
||||
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Terminal\Index;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
function terminalServer(string $uuid, string $name, array $containers): Server
|
||||
{
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->forceFill(['uuid' => $uuid, 'name' => $name]);
|
||||
$server->shouldReceive('isFunctional')->once()->andReturnTrue();
|
||||
$server->shouldReceive('loadAllContainers')->once()->andReturn(collect($containers));
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('keeps containers with the same name on different servers as distinct terminal targets', function () {
|
||||
$component = new Index;
|
||||
$component->servers = new Collection([
|
||||
terminalServer('pulse-uuid', 'Pulse', [
|
||||
['Names' => 'coolify-proxy', 'State' => 'running'],
|
||||
['Names' => 'coolify-sentinel', 'State' => 'running'],
|
||||
]),
|
||||
terminalServer('forge-uuid', 'Forge', [
|
||||
['Names' => 'coolify-proxy', 'State' => 'running'],
|
||||
['Names' => 'coolify-sentinel', 'State' => 'running'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$component->loadContainers();
|
||||
|
||||
expect($component->containers)->toHaveCount(4)
|
||||
->and(collect($component->containers)->pluck('uuid')->all())->toBe([
|
||||
'pulse-uuid:coolify-proxy',
|
||||
'forge-uuid:coolify-proxy',
|
||||
'pulse-uuid:coolify-sentinel',
|
||||
'forge-uuid:coolify-sentinel',
|
||||
]);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ use App\Jobs\VolumeBackupJob;
|
||||
use App\Jobs\VolumeBackupRecoveryJob;
|
||||
use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBackup;
|
||||
use App\Livewire\Project\Service\FileStorage;
|
||||
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
|
||||
use App\Livewire\Project\Shared\Storages\Show;
|
||||
use App\Livewire\Project\Shared\Storages\VolumeBackups;
|
||||
use App\Models\Application;
|
||||
@@ -20,6 +21,7 @@ use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
@@ -187,6 +189,45 @@ it('creates a scheduled backup with a preselected volume from the shared modal',
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('shows readable service storage backup target labels', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
[$application] = createVolumeBackupApplication($team);
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $application->environment_id,
|
||||
'destination_id' => $application->destination_id,
|
||||
'destination_type' => $application->destination_type,
|
||||
]);
|
||||
$resource = ServiceApplication::create([
|
||||
'uuid' => new_public_id(),
|
||||
'name' => 'directus',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $service->uuid.'_directus-templates',
|
||||
'mount_path' => '/directus/templates',
|
||||
'resource_id' => $resource->id,
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
]);
|
||||
LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
||||
'uuid' => new_public_id(),
|
||||
'fs_path' => './uploads',
|
||||
'mount_path' => '/directus/uploads',
|
||||
'is_directory' => true,
|
||||
'is_based_on_git' => false,
|
||||
'is_preview_suffix_enabled' => true,
|
||||
'resource_id' => $resource->id,
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
])));
|
||||
|
||||
Livewire::test(CreateServiceVolumeBackup::class, ['service' => $service])
|
||||
->assertSet('targets.0.name', 'directus-templates')
|
||||
->assertSet('targets.0.type', 'Directus')
|
||||
->assertSet('targets.1.name', './uploads (directory)')
|
||||
->assertSet('targets.1.type', 'Directus')
|
||||
->assertSee('Directus: directus-templates');
|
||||
});
|
||||
|
||||
it('handles scheduled backup persistence failures', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
it('provides a local helper build and deployment workflow', function () {
|
||||
$script = dirname(__DIR__, 2).'/scripts/dev-helper';
|
||||
|
||||
expect($script)->toBeFile()
|
||||
->and(is_executable($script))->toBeTrue();
|
||||
|
||||
exec('bash -n '.escapeshellarg($script), $output, $exitCode);
|
||||
|
||||
expect($exitCode)->toBe(0)
|
||||
->and(file_get_contents($script))
|
||||
->toContain('build)')
|
||||
->toContain('use)')
|
||||
->toContain('verify)')
|
||||
->toContain('deploy)')
|
||||
->toContain('reset)')
|
||||
->toContain('test)');
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
it('installs the current Docker CLI in the Coolify helper image', function () {
|
||||
$dockerfile = file_get_contents(dirname(__DIR__, 2).'/docker/coolify-helper/Dockerfile');
|
||||
expect($dockerfile)
|
||||
->toContain('ARG DOCKER_VERSION=29.7.2')
|
||||
->toContain('ARG DOCKER_COMPOSE_VERSION=5.5.0')
|
||||
->toContain('ARG DOCKER_BUILDX_VERSION=0.36.1')
|
||||
->toContain('https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz')
|
||||
->toContain('https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz')
|
||||
->toMatch('/chmod \+x [^\n]*\/usr\/bin\/docker/');
|
||||
});
|
||||
@@ -28,3 +28,8 @@ it('defaults empty values for an invalid URL', function () {
|
||||
it('preserves an explicitly configured default port', function () {
|
||||
expect(DomainUrlParts::split('https://app.example.com:443')['port'])->toBe('443');
|
||||
});
|
||||
|
||||
it('composes a domain when Livewire hydrates an empty numeric port as null', function () {
|
||||
expect(DomainUrlParts::compose('https', 'app.example.com', null))
|
||||
->toBe('https://app.example.com');
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve
|
||||
->toContain('ARG COOLIFY_VERSION')
|
||||
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
|
||||
->and($constants)
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.10'")
|
||||
->and($versions['coolify']['v4']['version'])->toBe('4.3.10')
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.11'")
|
||||
->and($versions['coolify']['v4']['version'])->toBe('4.3.11')
|
||||
->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1')
|
||||
->and($nightlyVersions)->toBe($versions);
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.3.10"
|
||||
"version": "4.3.11"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.4-rc.1"
|
||||
|
||||
Reference in New Issue
Block a user