Merge remote-tracking branch 'origin/main' into 11366-terminal-websocket-connection

This commit is contained in:
Andras Bacsai
2026-08-22 13:18:52 +02:00
37 changed files with 1669 additions and 173 deletions
+5 -3
View File
@@ -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.'
+29
View File
@@ -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.
+142
View File
@@ -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);
}
+90
View File
@@ -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();
});
}
}
+212 -54
View File
@@ -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'];
}
}
+182 -43
View File
@@ -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)',
]));
}
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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,
+16 -6
View File
@@ -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;
}
}
}
}
+2 -2
View File
@@ -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, '#')) {
+5 -4
View File
@@ -4147,11 +4147,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');
+1 -1
View File
@@ -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',
+3 -3
View File
@@ -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 -1
View File
@@ -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',
};
+280
View File
@@ -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);
});
});
+113 -17
View File
@@ -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();
});
+119
View File
@@ -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}"');
});
+55
View File
@@ -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);
});
+8
View File
@@ -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');
});
+48 -12
View File
@@ -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',
]);
});
+41
View File
@@ -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);
+19
View File
@@ -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)');
});
+12
View File
@@ -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/');
});
+5
View File
@@ -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');
});
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.3.10"
"version": "4.3.11"
},
"nightly": {
"version": "4.4-rc.1"