feat: add Traefik ACME cert UI and shared managed DNS record ownership

- Proxy: list and delete Traefik ACME certificates from the server proxy
  page via new TraefikAcmeService and Get/DeleteTraefikCertificate actions
- DNS: track ownership and cross-resource references for managed DNS
  records so records are only deleted when no longer referenced; release
  records asynchronously on resource deletion via ReleaseManagedDnsRecordsJob
  and ManagedDnsRecordCleanup; harden Cloudflare provider deletion results
- Databases: fail closed on start when prerequisites or the CA certificate
  are missing (DatabaseStartException, Server::ensureCaCertificate) and
  clean up stale start activities via ResourceStartActivity
- Webhooks: throttle repeated manual webhook signature failures for
  GitHub, GitLab, Gitea and Bitbucket
- Deployments: improve compose build-context handling and compose file
  load error reporting
- Install scripts: rework terminal UI output in install.sh (stable and
  nightly)
- Misc: settings sidebar accordion fixes, log drain toggle rollback,
  add Serverside to README sponsors
- Add migrations and tests covering the above
This commit is contained in:
Andras Bacsai
2026-09-25 19:00:00 +02:00
parent 84b596f7ca
commit 554b79e8dd
81 changed files with 3960 additions and 633 deletions
+1
View File
@@ -121,6 +121,7 @@ Thank you so much!
### Small Sponsors
<a href="https://serverside.com/?utm_source=coolify.io"><img width="60px" alt="Serverside" src="https://cdn.coollabs.io/sponsors/serverside.svg"/></a>
<a href="https://darkvps.pro"><img width="60px" alt="DarkVPS" src="https://cdn.coollabs.io/sponsors/darkvps.png"/></a>
<a href="https://www.opensourcealternatives.to"><img width="60px" alt="Open Source Alternatives" src="https://cdn.coollabs.io/sponsors/opensourcealternatives.png"/></a>
<a href="https://onserva.com/"><img width="60px" alt="Onserva" src="https://onserva.com/icon.svg"/></a>
+4
View File
@@ -22,6 +22,10 @@ class RestartDatabase
if (! $server->isFunctional()) {
return 'Server is not functional';
}
$prerequisiteError = StartDatabase::prerequisiteError($database);
if ($prerequisiteError !== null) {
return $prerequisiteError;
}
StopDatabase::run($database, dockerCleanup: false);
return StartDatabase::run($database);
+38 -8
View File
@@ -4,6 +4,7 @@ namespace App\Actions\Database;
use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Exceptions\DatabaseStartException;
use App\Jobs\DatabaseStartJob;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
@@ -13,9 +14,11 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\ResourceStartActivity;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Spatie\Activitylog\Models\Activity;
use Throwable;
class StartDatabase
{
@@ -32,6 +35,10 @@ class StartDatabase
if (! $server->isFunctional()) {
return 'Server is not functional';
}
$prerequisiteError = self::prerequisiteError($database);
if ($prerequisiteError !== null) {
return $prerequisiteError;
}
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
@@ -45,7 +52,7 @@ class StartDatabase
'type_uuid' => $database->uuid,
'status' => ProcessStatus::QUEUED->value,
'team_id' => $server->team_id,
'operation' => 'database-start',
'operation' => ResourceStartActivity::DATABASE_START_OPERATION,
])
->performedOn($database)
->event(ActivityTypes::INLINE->value)
@@ -55,13 +62,19 @@ class StartDatabase
return 'Database start could not be queued because activity logging is disabled.';
}
DatabaseStartJob::dispatch(
$database->getMorphClass(),
(int) $database->getKey(),
(int) $database->team()->id,
(int) $activity->getKey(),
auth()->id(),
);
try {
DatabaseStartJob::dispatch(
$database->getMorphClass(),
(int) $database->getKey(),
(int) $database->team()->id,
(int) $activity->getKey(),
auth()->id(),
);
} catch (Throwable $e) {
ResourceStartActivity::markFailed($activity, 'Database start could not be queued.');
throw $e;
}
if ($database->is_public && $database->public_port) {
StartDatabaseProxy::dispatch($database);
@@ -69,4 +82,21 @@ class StartDatabase
return $activity;
}
/**
* Check prerequisites that would make the queued start fail, so the user gets the
* error immediately instead of a queued start that can only fail later.
*/
public static function prerequisiteError(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): ?string
{
if (! $database->enable_ssl) {
return null;
}
if (! $database->destination->server->ensureCaCertificate()) {
return DatabaseStartException::missingCaCertificate()->getMessage();
}
return null;
}
}
+2 -12
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Database;
use App\Exceptions\DatabaseStartException;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
@@ -59,18 +60,7 @@ class StartDragonfly
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate();
$this->ssl_certificate = $this->database->sslCertificates()->first();
+2 -12
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Database;
use App\Exceptions\DatabaseStartException;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
@@ -59,18 +60,7 @@ class StartKeydb
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate();
$this->ssl_certificate = $this->database->sslCertificates()->first();
+2 -12
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Database;
use App\Exceptions\DatabaseStartException;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
@@ -59,18 +60,7 @@ class StartMariadb
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate();
$this->ssl_certificate = $this->database->sslCertificates()->first();
+2 -12
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Database;
use App\Exceptions\DatabaseStartException;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
@@ -66,18 +67,7 @@ class StartMongodb
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate();
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
+2 -12
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Database;
use App\Exceptions\DatabaseStartException;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
@@ -61,18 +62,7 @@ class StartMysql
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate();
$this->ssl_certificate = $this->database->sslCertificates()->first();
+2 -12
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Database;
use App\Exceptions\DatabaseStartException;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
@@ -65,18 +66,7 @@ class StartPostgresql
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate();
$this->ssl_certificate = $this->database->sslCertificates()->first();
+2 -12
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Database;
use App\Exceptions\DatabaseStartException;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
@@ -61,18 +62,7 @@ class StartRedis
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate();
$this->ssl_certificate = $this->database->sslCertificates()->first();
@@ -0,0 +1,57 @@
<?php
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Services\TraefikAcmeService;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class DeleteTraefikCertificate
{
use AsAction;
public function __construct(
private GetTraefikCertificates $getTraefikCertificates,
private TraefikAcmeService $acmeService,
) {}
public function handle(Server $server, string $certificateId): void
{
if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) {
throw new RuntimeException('TLS certificates can only be managed for Traefik proxies.');
}
$contents = $this->getTraefikCertificates->contents($server);
if ($contents === null) {
throw new RuntimeException('The Traefik ACME file could not be found.');
}
$certificate = collect($this->acmeService->certificates($contents))
->firstWhere('id', $certificateId);
if ($certificate === null) {
throw new RuntimeException('The selected certificate could not be found.');
}
$updatedContents = $this->acmeService->deleteCertificate(
$contents,
$certificate['resolver'],
$certificateId,
);
$path = rtrim($server->proxyPath(), '/').'/acme.json';
$temporaryPath = $path.'.coolify-'.bin2hex(random_bytes(8));
$encodedContents = base64_encode($updatedContents);
$script = sprintf(
'set -e; umask 077; printf %%s %s | base64 -d > %s; chmod 600 %s; mv -- %s %s',
escapeshellarg($encodedContents),
escapeshellarg($temporaryPath),
escapeshellarg($temporaryPath),
escapeshellarg($temporaryPath),
escapeshellarg($path),
);
instant_remote_process(['sh -c '.escapeshellarg($script)], $server);
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Services\TraefikAcmeService;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class GetTraefikCertificates
{
use AsAction;
public const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
public function __construct(private TraefikAcmeService $acmeService) {}
/** @return array<int, array{id: string, resolver: string, main_domain: string, sans: array<int, string>, store: ?string, expires_at: ?string}> */
public function handle(Server $server): array
{
if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return [];
}
$contents = $this->contents($server);
return $contents === null ? [] : $this->acmeService->certificates($contents);
}
public function contents(Server $server): ?string
{
$path = escapeshellarg(rtrim($server->proxyPath(), '/').'/acme.json');
$readLimit = self::MAX_FILE_SIZE_BYTES + 1;
$tooLargeMarker = '__COOLIFY_ACME_FILE_TOO_LARGE__';
$output = instant_remote_process([
"if [ ! -f {$path} ]; then exit 0; elif [ \"\$(wc -c < {$path})\" -gt ".self::MAX_FILE_SIZE_BYTES." ]; then echo '{$tooLargeMarker}'; else head -c {$readLimit} {$path} | base64 | tr -d '\\n'; fi",
], $server, false);
if ($output === null || trim($output) === '') {
return null;
}
if (trim($output) === $tooLargeMarker) {
throw new RuntimeException('The Traefik ACME file exceeds the 10 MiB size limit.');
}
$contents = base64_decode(trim($output), true);
if ($contents === false || strlen($contents) > self::MAX_FILE_SIZE_BYTES) {
throw new RuntimeException('The Traefik ACME file could not be read safely.');
}
return $contents;
}
}
+6 -4
View File
@@ -51,14 +51,16 @@ class DeleteService
throw new RuntimeException('Server is not functional.');
}
$this->removeContainers($service, $resource->id);
$this->removeContainers($service, $resource);
}
private function removeContainers(Service $service, ?int $subresourceId = null): void
private function removeContainers(Service $service, ServiceApplication|ServiceDatabase|null $subresource = null): void
{
$filters = "--filter 'label=coolify.serviceId={$service->id}'";
if ($subresourceId !== null) {
$filters .= " --filter 'label=coolify.service.subId={$subresourceId}'";
if ($subresource !== null) {
// Applications and databases are separate tables, so an id alone can match the other type.
$subType = $subresource instanceof ServiceDatabase ? 'database' : 'application';
$filters .= " --filter 'label=coolify.service.subId={$subresource->id}' --filter 'label=coolify.service.subType={$subType}'";
}
$command = "container_ids=\$(docker ps -aq {$filters}); [ -z \"\$container_ids\" ] || docker rm -f \$container_ids";
+11
View File
@@ -15,6 +15,7 @@ use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\User;
use App\Support\ResourceStartActivity;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
@@ -105,6 +106,16 @@ class Init extends Command
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
}
try {
$interruptedStartCount = ResourceStartActivity::failInterrupted();
if ($interruptedStartCount > 0) {
echo "Marked {$interruptedStartCount} interrupted database/service starts as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup interrupted database/service starts: {$e->getMessage()}\n";
}
try {
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
'status' => 'failed',
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Enums;
enum ManagedDnsDeletionResult: string
{
/** The provider record was deleted. */
case Deleted = 'deleted';
/** The provider record no longer exists. */
case AlreadyGone = 'already_gone';
/** Coolify did not create the record, so it is never deleted. */
case NotOwned = 'not_owned';
/** The record no longer carries Coolify's ownership comment or its value changed. */
case ChangedExternally = 'changed_externally';
/** The provider could not be reached or rejected the request; retry later. */
case Failed = 'failed';
public function removedFromProvider(): bool
{
return $this === self::Deleted || $this === self::AlreadyGone;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* Expected, user-facing reason why a database could not be started
* (for example a missing prerequisite). The message is shown in the start log.
*/
class DatabaseStartException extends Exception
{
public static function missingCaCertificate(): static
{
return new static('No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
}
public static function startCommandsDidNotRun(): static
{
return new static('Database start ended without running the start commands.');
}
}
+1
View File
@@ -35,6 +35,7 @@ class Handler extends ExceptionHandler
ProcessException::class,
NonReportableException::class,
DeploymentException::class,
DatabaseStartException::class,
];
/**
+6 -2
View File
@@ -20,6 +20,10 @@ class Bitbucket extends Controller
public function manual(Request $request)
{
if ($this->hasTooManyManualWebhookFailures($request, 'bitbucket')) {
return $this->tooManyManualWebhookFailuresResponse($request, 'bitbucket');
}
try {
$return_payloads = collect([]);
$payload = $request->collect();
@@ -74,7 +78,7 @@ class Bitbucket extends Controller
}
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response([$this->unauthenticatedManualWebhookFailurePayload()]);
return $this->unauthenticatedManualWebhookResponse($request, 'bitbucket');
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
@@ -275,7 +279,7 @@ class Bitbucket extends Controller
}
}
return $this->manualWebhookResponse($return_payloads);
return $this->manualWebhookResponse($return_payloads, $request, 'bitbucket');
} catch (Exception $e) {
return handleError($e);
}
@@ -4,11 +4,14 @@ namespace App\Http\Controllers\Webhook\Concerns;
use App\Models\Application;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
trait MatchesManualWebhookApplications
{
use ThrottlesManualWebhookFailures;
protected function manualWebhookRepositoryFullName(mixed $fullName): ?string
{
if (! is_string($fullName)) {
@@ -62,12 +65,23 @@ trait MatchesManualWebhookApplications
];
}
protected function manualWebhookResponse(Collection $payloads): Response
/**
* Respond to a delivery that could not be authenticated (no matching
* application or no signature) and count it as a failed attempt.
*/
protected function unauthenticatedManualWebhookResponse(Request $request, string $provider): Response
{
$this->recordManualWebhookFailure($request, $provider);
return response([$this->unauthenticatedManualWebhookFailurePayload()]);
}
protected function manualWebhookResponse(Collection $payloads, Request $request, string $provider): Response
{
$failure = $this->unauthenticatedManualWebhookFailurePayload();
$authorizedPayloads = $payloads->reject(fn (array $payload): bool => $payload === $failure)->values();
if ($authorizedPayloads->isEmpty() && $payloads->isNotEmpty()) {
return response([$failure]);
return $this->unauthenticatedManualWebhookResponse($request, $provider);
}
return response($authorizedPayloads);
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
/**
* Throttles manual webhook deliveries that fail authentication.
*
* Only failed authentication attempts are counted, per provider and client IP,
* so valid signed deliveries from shared IPs (Cloudflare, Git host IP pools)
* are never dropped because of unrelated traffic.
*/
trait ThrottlesManualWebhookFailures
{
protected const int MANUAL_WEBHOOK_MAX_FAILURES = 30;
protected const int MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS = 60;
protected function manualWebhookFailureRateLimitKey(Request $request, string $provider): string
{
return "manual-webhook-failures:{$provider}:".auth_rate_limit_ip($request);
}
protected function hasTooManyManualWebhookFailures(Request $request, string $provider): bool
{
return RateLimiter::tooManyAttempts($this->manualWebhookFailureRateLimitKey($request, $provider), self::MANUAL_WEBHOOK_MAX_FAILURES);
}
protected function tooManyManualWebhookFailuresResponse(Request $request, string $provider): Response
{
$retryAfter = RateLimiter::availableIn($this->manualWebhookFailureRateLimitKey($request, $provider));
return response([
'status' => 'failed',
'message' => 'Too many failed webhook authentication attempts. Try again later.',
], 429)->header('Retry-After', (string) max($retryAfter, 1));
}
protected function recordManualWebhookFailure(Request $request, string $provider): void
{
RateLimiter::hit($this->manualWebhookFailureRateLimitKey($request, $provider), self::MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS);
}
}
+7 -3
View File
@@ -21,6 +21,10 @@ class Gitea extends Controller
public function manual(Request $request)
{
if ($this->hasTooManyManualWebhookFailures($request, 'gitea')) {
return $this->tooManyManualWebhookFailuresResponse($request, 'gitea');
}
try {
$return_payloads = collect([]);
$x_gitea_delivery = request()->header('X-Gitea-Delivery');
@@ -69,13 +73,13 @@ class Gitea extends Controller
if ($x_gitea_event === 'push') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response([$this->unauthenticatedManualWebhookFailurePayload()]);
return $this->unauthenticatedManualWebhookResponse($request, 'gitea');
}
}
if ($x_gitea_event === 'pull_request') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response([$this->unauthenticatedManualWebhookFailurePayload()]);
return $this->unauthenticatedManualWebhookResponse($request, 'gitea');
}
}
foreach ($applications as $application) {
@@ -281,7 +285,7 @@ class Gitea extends Controller
}
}
return $this->manualWebhookResponse($return_payloads);
return $this->manualWebhookResponse($return_payloads, $request, 'gitea');
} catch (Exception $e) {
return handleError($e);
}
+7 -3
View File
@@ -25,6 +25,10 @@ class Github extends Controller
public function manual(Request $request)
{
if ($this->hasTooManyManualWebhookFailures($request, 'github')) {
return $this->tooManyManualWebhookFailuresResponse($request, 'github');
}
try {
$return_payloads = collect([]);
$x_github_delivery = request()->header('X-GitHub-Delivery');
@@ -79,7 +83,7 @@ class Github extends Controller
if ($x_github_event === 'push') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response([$this->unauthenticatedManualWebhookFailurePayload()]);
return $this->unauthenticatedManualWebhookResponse($request, 'github');
}
}
if ($x_github_event === 'pull_request') {
@@ -88,7 +92,7 @@ class Github extends Controller
}
$applications = $this->manualWebhookApplications($applications, $full_name);
if ($applications->isEmpty()) {
return response([$this->unauthenticatedManualWebhookFailurePayload()]);
return $this->unauthenticatedManualWebhookResponse($request, 'github');
}
}
$applicationsByServer = $applications->groupBy(function ($app) {
@@ -239,7 +243,7 @@ class Github extends Controller
}
}
return $this->manualWebhookResponse($return_payloads);
return $this->manualWebhookResponse($return_payloads, $request, 'github');
} catch (Exception $e) {
return handleError($e);
}
+8 -12
View File
@@ -336,6 +336,10 @@ class Gitlab extends Controller
public function manual(Request $request)
{
if ($this->hasTooManyManualWebhookFailures($request, 'gitlab')) {
return $this->tooManyManualWebhookFailuresResponse($request, 'gitlab');
}
try {
$return_payloads = collect([]);
$payload = $request->collect();
@@ -356,12 +360,8 @@ class Gitlab extends Controller
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
]);
return response($return_payloads);
return $this->unauthenticatedManualWebhookResponse($request, 'gitlab');
}
if ($x_gitlab_event === 'push') {
@@ -416,17 +416,13 @@ class Gitlab extends Controller
if ($x_gitlab_event === 'push') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
return response($return_payloads);
return $this->unauthenticatedManualWebhookResponse($request, 'gitlab');
}
}
if ($x_gitlab_event === 'merge_request') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
return response($return_payloads);
return $this->unauthenticatedManualWebhookResponse($request, 'gitlab');
}
}
foreach ($applications as $application) {
@@ -635,7 +631,7 @@ class Gitlab extends Controller
}
}
return $this->manualWebhookResponse($return_payloads);
return $this->manualWebhookResponse($return_payloads, $request, 'gitlab');
} catch (Exception $e) {
return handleError($e);
}
+23 -49
View File
@@ -758,8 +758,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return;
}
$this->validateComposeBuildPaths($composeFile);
// Add build secrets to compose file if enabled and BuildKit is supported
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$composeFile = $this->add_build_secrets_to_compose($composeFile);
@@ -4915,7 +4913,13 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
$dockerfilePath = $this->resolveComposeDockerfilePath($service['build']);
$fullDockerfilePath = escapeshellarg("{$this->workdir}/{$dockerfilePath}");
if ($dockerfilePath === null) {
$this->application_deployment_queue->addLogEntry("The build context of service {$serviceName} is remote or uses variables, skipping ARG injection.");
continue;
}
// Compose resolves relative paths from the project directory (the workdir).
$fullDockerfilePath = escapeshellarg(str_starts_with($dockerfilePath, '/') ? $dockerfilePath : "{$this->workdir}/{$dockerfilePath}");
// BusyBox realpath in the helper image accepts no options; a missing file prints nothing.
$this->execute_remote_command([
@@ -4924,8 +4928,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
'save' => 'dockerfile_check_'.$serviceName,
]);
// A monorepo context may leave the base directory, but never the cloned repository.
$resolvedDockerfilePath = str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString();
if (! str_starts_with($resolvedDockerfilePath, "{$this->workdir}/")) {
if (! str_starts_with($resolvedDockerfilePath, "{$this->basedir}/")) {
$this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection.");
continue;
@@ -5042,60 +5047,29 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
private function validateComposeBuildPaths(array|Collection $composeFile): void
{
foreach (data_get($composeFile, 'services', []) as $service) {
if (isset($service['build'])) {
$this->resolveComposeDockerfilePath($service['build']);
}
}
}
private function resolveComposeDockerfilePath(mixed $build): string
/**
* Returns the Dockerfile path of a Compose build as written (relative to the project directory,
* or absolute), or null when Coolify cannot inspect it locally: a remote git context, a path that
* Compose fills in from variables, or an inline Dockerfile. The path is untrusted: the caller
* quotes it and only uses the resolved file when it is inside the cloned repository.
*/
private function resolveComposeDockerfilePath(mixed $build): ?string
{
if (! is_string($build) && ! is_array($build)) {
throw new \RuntimeException('Invalid Docker Compose build definition.');
return null;
}
$context = is_string($build) ? $build : data_get($build, 'context', '.');
$dockerfile = is_array($build) ? data_get($build, 'dockerfile', 'Dockerfile') : 'Dockerfile';
if (! is_string($context) || ! is_string($dockerfile)) {
throw new \RuntimeException('Invalid Docker Compose build path: context and dockerfile must be strings.');
if (! is_string($context) || ! is_string($dockerfile) || $context === '' || (is_array($build) && array_key_exists('dockerfile_inline', $build))) {
return null;
}
if (str_contains($context.$dockerfile, '$') || preg_match('~^[a-z][a-z0-9+.-]*://|^git@~i', $context) === 1) {
return null;
}
$this->validateComposeBuildPath($context, 'context');
$this->validateComposeBuildPath($dockerfile, 'dockerfile');
return $this->normalizeComposeBuildPath("{$context}/{$dockerfile}", 'dockerfile');
}
private function validateComposeBuildPath(string $path, string $fieldName): void
{
if ($path === '' || str_starts_with($path, '/') || ! preg_match('/^[a-zA-Z0-9._\-\/@+]+$/', $path)) {
throw new \RuntimeException("Invalid Docker Compose build.{$fieldName} path.");
}
}
private function normalizeComposeBuildPath(string $path, string $fieldName): string
{
$segments = [];
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
if ($segments === []) {
throw new \RuntimeException("Invalid Docker Compose build.{$fieldName} path: path traversal outside the repository.");
}
array_pop($segments);
continue;
}
$segments[] = $segment;
}
return $segments === [] ? '.' : implode('/', $segments);
return str_starts_with($dockerfile, '/') ? $dockerfile : rtrim($context, '/').'/'.$dockerfile;
}
private function add_build_secrets_to_compose($composeFile)
+11 -7
View File
@@ -12,6 +12,7 @@ use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StartRedis;
use App\Enums\ProcessStatus;
use App\Events\DatabaseStatusChanged;
use App\Exceptions\DatabaseStartException;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@@ -20,6 +21,7 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\ResourceStartActivity;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -53,7 +55,7 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
abort_unless((int) $database->team()->id === $this->teamId, 403);
$activity = Activity::query()->findOrFail($this->activityId);
match ($database->getMorphClass()) {
$result = match ($database->getMorphClass()) {
StandalonePostgresql::class => StartPostgresql::run($database, $activity),
StandaloneRedis::class => StartRedis::run($database, $activity),
StandaloneMongodb::class => StartMongodb::run($database, $activity),
@@ -64,6 +66,10 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
StandaloneClickhouse::class => StartClickhouse::run($database, $activity),
};
if (! $result instanceof Activity || data_get($result, 'properties.status') !== ProcessStatus::FINISHED->value) {
throw DatabaseStartException::startCommandsDidNotRun();
}
event(new DatabaseStatusChanged($this->userId));
}
@@ -75,12 +81,10 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
return;
}
$activity->properties = $activity->properties->merge([
'status' => ProcessStatus::ERROR->value,
'error' => 'Database start failed.',
'failed_at' => now()->toIso8601String(),
]);
$activity->save();
ResourceStartActivity::markFailed(
$activity,
$exception instanceof DatabaseStartException ? $exception->getMessage() : 'Database start failed.',
);
} finally {
event(new DatabaseStatusChanged($this->userId));
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Jobs;
use App\Services\Dns\ManagedDnsRecordCleanup;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
/**
* Releases the managed DNS references of a deleted resource and deletes records Coolify owns that are no longer used.
* Never fails the deletion: provider errors are logged and retried a few times.
*/
class ReleaseManagedDnsRecordsJob implements ShouldQueue
{
use Queueable;
public const MAX_ATTEMPTS = 3;
public int $tries = self::MAX_ATTEMPTS;
public int $timeout = 120;
public function __construct(
public string $resourceType,
public int|string $resourceId,
) {}
public function handle(ManagedDnsRecordCleanup $cleanup): void
{
if ($cleanup->releaseResource($this->resourceType, $this->resourceId)) {
return;
}
if ($this->attempts() < self::MAX_ATTEMPTS) {
$this->release(60 * $this->attempts());
return;
}
Log::warning('Managed DNS records of a deleted resource could not be cleaned up; they were left in place.', [
'resource_type' => $this->resourceType,
'resource_id' => $this->resourceId,
]);
}
}
@@ -2,11 +2,13 @@
namespace App\Livewire\Concerns;
use App\Enums\ManagedDnsDeletionResult;
use App\Exceptions\DnsRecordConflictException;
use App\Jobs\ConfigureDnsRecordJob;
use App\Models\DnsProviderZone;
use App\Models\ManagedDnsRecord;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\Dns\ManagedDnsRecordCleanup;
use Illuminate\Database\Eloquent\Model;
trait InteractsWithDnsProviders
@@ -48,7 +50,8 @@ trait InteractsWithDnsProviders
return;
}
try {
$cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname));
$record = $cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname));
$this->referenceDnsRecordFromAllResources($record, $hostname);
$this->markDnsManaged($hostname, $zone->integrationToken->name);
$this->dispatch('success', "DNS record created for {$hostname}.");
$this->loadDnsProviderProposals();
@@ -146,7 +149,7 @@ trait InteractsWithDnsProviders
return;
}
try {
app(CloudflareDnsProvider::class)->replaceRecord(
$record = app(CloudflareDnsProvider::class)->replaceRecord(
$zone,
(string) ($conflict['record_id'] ?? ''),
$hostname,
@@ -154,6 +157,7 @@ trait InteractsWithDnsProviders
$this->dnsResourceForHostname($hostname),
(string) ($conflict['current'] ?? ''),
);
$this->referenceDnsRecordFromAllResources($record, $hostname);
unset($this->dnsProviderConflicts[$key]);
$this->dispatch('success', "DNS record replaced for {$hostname}.");
$this->loadDnsProviderProposals();
@@ -220,36 +224,46 @@ trait InteractsWithDnsProviders
$this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}");
}
protected function deleteManagedDnsForUrl(string $url): void
/**
* Releases the resource's reference to the managed DNS record of a removed URL. The provider record is deleted only when
* $deleteRecord is set, Coolify created it, and no other resource or URL (in any team) still uses the hostname.
*/
protected function releaseManagedDnsForUrl(string $url, ?Model $resource = null, bool $deleteRecord = true): void
{
$hostname = parse_url($url, PHP_URL_HOST);
if (! is_string($hostname)) {
return;
}
$resource = $this->dnsResourceForHostname($hostname);
$resource ??= $this->dnsResourceForHostname($hostname);
if ($resource === null) {
return;
}
$record = ManagedDnsRecord::query()
->where('team_id', currentTeam()->id)
->where('name', strtolower($hostname))
->where('resource_type', $resource->getMorphClass())
->where('resource_id', $resource->getKey())
->first();
$result = app(ManagedDnsRecordCleanup::class)->releaseHostname($resource, $hostname, currentTeam()->id, $deleteRecord);
if ($record !== null && ! app(CloudflareDnsProvider::class)->deleteRecord($record)) {
auditLog('ui.dns_record.delete_skipped', [
'team_id' => currentTeam()->id,
'hostname' => $hostname,
'provider' => 'cloudflare',
'reason' => 'remote_record_changed',
], 'warning');
if ($result === ManagedDnsDeletionResult::ChangedExternally) {
$this->dispatch('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.');
} elseif ($result === ManagedDnsDeletionResult::Failed) {
$this->dispatch('warning', 'The domain was removed, but its DNS record could not be deleted because Cloudflare did not respond. It was left untouched.');
}
}
/**
* Service domains can share a hostname across several service applications; each of them references the record.
*/
protected function referenceDnsRecordFromAllResources(ManagedDnsRecord $record, string $hostname): void
{
if (property_exists($this, 'application') || ! property_exists($this, 'service') || $this->service === null) {
return;
}
$cleanup = app(ManagedDnsRecordCleanup::class);
$this->service->applications()->get()
->filter(fn (Model $application): bool => in_array(strtolower($hostname), $cleanup->hostnamesOf($application), true))
->each(fn (Model $application) => $record->addReference($application));
}
protected function authorizeDnsProviderChange(): void
{
$this->authorize('update', property_exists($this, 'application') ? $this->application : $this->service);
+2 -4
View File
@@ -211,7 +211,7 @@ class Domains extends Component
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."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
@@ -1601,9 +1601,7 @@ class Domains extends Component
return;
}
if (in_array('deleteManagedDns', $selectedActions, true)) {
$this->deleteManagedDnsForUrl($url);
}
$this->releaseManagedDnsForUrl($url, $this->application, in_array('deleteManagedDns', $selectedActions, true));
if ($this->editingIndex === $index) {
$this->cancelEdit();
@@ -391,7 +391,7 @@ class PreviewDomains extends Component
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."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
+14 -11
View File
@@ -6,11 +6,10 @@ use App\Actions\Database\RestartDatabase;
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StopDatabase;
use App\Actions\Docker\GetContainersStatus;
use App\Enums\ProcessStatus;
use App\Events\ServiceStatusChanged;
use App\Support\ResourceStartActivity;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Heading extends Component
{
@@ -79,15 +78,9 @@ class Heading extends Component
public function checkDeployments()
{
try {
$activity = Activity::where('properties->type_uuid', $this->database->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) {
$this->isDeploymentProgress = true;
$this->runningActivityId = $activity->id;
} else {
$this->isDeploymentProgress = false;
$this->runningActivityId = null;
}
$activity = ResourceStartActivity::latestRunning($this->database->uuid);
$this->isDeploymentProgress = $activity !== null;
$this->runningActivityId = $activity?->id;
} catch (\Throwable) {
$this->isDeploymentProgress = false;
$this->runningActivityId = null;
@@ -157,6 +150,11 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database);
if (is_string($activity)) {
$this->dispatch('error', $activity);
return;
}
$this->auditDatabaseAction('ui.database.restarted');
$this->markDeploymentRunning($activity);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
@@ -172,6 +170,11 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database);
if (is_string($activity)) {
$this->dispatch('error', $activity);
return;
}
$this->auditDatabaseAction('ui.database.started');
$this->markDeploymentRunning($activity);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
+2 -4
View File
@@ -192,7 +192,7 @@ class Domains extends Component
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."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record. If you changed it recently, DNS propagation can take some time, so please try again later."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
@@ -1357,9 +1357,7 @@ class Domains extends Component
return;
}
if (in_array('deleteManagedDns', $selectedActions, true)) {
$this->deleteManagedDnsForUrl($url);
}
$this->releaseManagedDnsForUrl($url, $app, in_array('deleteManagedDns', $selectedActions, true));
$this->forceSaveDomains = false;
$this->forceRemovePort = false;
+4 -9
View File
@@ -10,6 +10,7 @@ use App\Enums\ProcessStatus;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\ResourceStartActivity;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
@@ -103,15 +104,9 @@ class Heading extends Component
$this->authorizeService('view');
try {
$activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) {
$this->isDeploymentProgress = true;
$this->runningActivityId = $activity->id;
} else {
$this->isDeploymentProgress = false;
$this->runningActivityId = null;
}
$activity = ResourceStartActivity::latestRunning($this->service->uuid);
$this->isDeploymentProgress = $activity !== null;
$this->runningActivityId = $activity?->id;
} catch (\Throwable) {
$this->isDeploymentProgress = false;
$this->runningActivityId = null;
+1 -1
View File
@@ -128,7 +128,6 @@ class LogDrains extends Component
$this->syncDataAxiom($toModel);
$this->syncDataCustom($toModel);
}
$this->auditLogDrain($this->{$enabledProperty} ? 'enabled' : 'disabled', $type);
}
}
@@ -210,6 +209,7 @@ class LogDrains extends Component
}
$this->syncData(true);
$this->auditLogDrain($this->{$enabledProperty} ? 'enabled' : 'disabled', $type);
if ($this->server->isLogDrainEnabled()) {
StartLogDrain::run($this->server);
+33
View File
@@ -2,7 +2,9 @@
namespace App\Livewire\Server;
use App\Actions\Proxy\DeleteTraefikCertificate;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\GetTraefikCertificates;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Server;
@@ -26,6 +28,10 @@ class Proxy extends Component
public bool $generateExactLabels = false;
public array $traefikCertificates = [];
public bool $traefikCertificatesLoaded = false;
/**
* Cache the versions.json file data in memory for this component instance.
* This avoids multiple file reads during a single request/render cycle.
@@ -195,6 +201,7 @@ class Proxy extends Component
}
}
<<<<<<< Updated upstream
public function getTraefikVersionForWarningProperty(): ?string
{
if ($this->server->detected_traefik_version) {
@@ -211,6 +218,32 @@ class Proxy extends Component
}
return $matches[1];
=======
public function loadTraefikCertificates(): void
{
$this->traefikCertificates = [];
try {
$this->authorize('view', $this->server);
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->traefikCertificatesLoaded = true;
} catch (\Throwable $e) {
$this->traefikCertificatesLoaded = true;
handleError($e, $this);
}
}
public function deleteTraefikCertificate(string $certificateId, string $password = ''): void
{
try {
$this->authorize('update', $this->server);
DeleteTraefikCertificate::run($this->server, $certificateId);
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.');
} catch (\Throwable $e) {
handleError($e, $this);
}
>>>>>>> Stashed changes
}
/**
+82 -58
View File
@@ -11,19 +11,20 @@ use App\Services\DeploymentConfiguration\ConfigurationDiffer;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasMetrics;
use App\Traits\HasNoindexDomains;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use App\Traits\ReleasesManagedDnsRecords;
use Database\Factories\ApplicationFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use OpenApi\Attributes as OA;
@@ -128,7 +129,7 @@ use Symfony\Component\Yaml\Yaml;
class Application extends BaseModel
{
/** @use HasFactory<ApplicationFactory> */
use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, ReleasesManagedDnsRecords, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -1459,9 +1460,10 @@ class Application extends BaseModel
return application_configuration_dir()."/{$this->uuid}";
}
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null)
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null, ?string $baseDir = null, bool $onlyCheckout = false)
{
$baseDir = $this->generateBaseDir($deployment_uuid);
// The folder the repository was cloned into; a checkout on the server passes its own folder.
$baseDir ??= $this->generateBaseDir($deployment_uuid);
$escapedBaseDir = escapeshellarg($baseDir);
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
$gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
@@ -1487,7 +1489,8 @@ class Application extends BaseModel
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
}
}
if ($this->settings->is_git_submodules_enabled) {
// A checkout that only reads files (the Compose file) needs neither submodules nor LFS objects.
if ($this->settings->is_git_submodules_enabled && ! $onlyCheckout) {
// Check if .gitmodules file exists before running submodule commands
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then";
if ($public) {
@@ -1497,7 +1500,7 @@ class Application extends BaseModel
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
$git_clone_command = "{$git_clone_command} {$gitCommand} submodule sync && {$sshCommand} {$gitCommand} submodule update --init --recursive {$submoduleFlags}; fi";
}
if ($this->settings->is_git_lfs_enabled) {
if ($this->settings->is_git_lfs_enabled && ! $onlyCheckout) {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull";
}
@@ -1797,7 +1800,7 @@ class Application extends BaseModel
$gitConfigOptions = $this->withGitHttpTransportConfig();
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout);
}
if ($exec_in_docker) {
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
@@ -1826,7 +1829,7 @@ class Application extends BaseModel
}
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout);
}
if ($exec_in_docker) {
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
@@ -1874,7 +1877,7 @@ class Application extends BaseModel
if ($only_checkout) {
$git_clone_command = $git_clone_command_base;
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout);
}
if ($pull_request_id !== 0) {
@@ -1915,7 +1918,7 @@ class Application extends BaseModel
if ($only_checkout) {
$git_clone_command = $git_clone_command_base;
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout);
}
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
@@ -1949,7 +1952,7 @@ class Application extends BaseModel
if ($gitConfigOptions) {
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
}
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout);
if ($exec_in_docker) {
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
@@ -1978,7 +1981,7 @@ class Application extends BaseModel
if ($only_checkout) {
$git_clone_command = $git_clone_command_base;
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand, baseDir: $baseDir, onlyCheckout: $only_checkout);
}
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
if ($pull_request_id !== 0) {
@@ -2028,7 +2031,7 @@ class Application extends BaseModel
if ($gitConfigOptions) {
$git_clone_command = $this->applyGitConfigOptionsToCloneCommand($git_clone_command, $gitConfigOptions);
}
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions, baseDir: $baseDir, onlyCheckout: $only_checkout);
$otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
if ($pull_request_id !== 0) {
@@ -2151,32 +2154,44 @@ class Application extends BaseModel
}
}
public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null)
/**
* Logs a failed git command on the server and returns its last error line for the user.
* Credentials in URLs (such as GitHub App or GitLab tokens) are removed from both, and the
* line is HTML-escaped because error toasts render HTML.
*/
private function gitFailureReason(string $summary, string $errorOutput): string
{
// Use provided restore values or capture current values as fallback
$initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location;
$initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory;
if ($isInit && $this->docker_compose_raw) {
return;
}
$uuid = new_public_id();
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout');
$cloneCommand = $this->gitCommandsAsShellCommand($cloneCommand);
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand);
$errorOutput = (string) preg_replace('~([a-z][a-z0-9+.-]*://)[^/\s@\'"]+@~i', '$1***@', trim($errorOutput));
Log::warning($summary, [
'application_uuid' => $this->uuid,
'server_uuid' => $this->destination?->server?->uuid,
'error' => $errorOutput,
]);
$reason = collect(explode("\n", $errorOutput))->map(fn (string $line) => trim($line))->filter()->last();
return $reason ? '<br><br>Reason: '.e(str($reason)->limit(300)->value()) : '';
}
/**
* Commands that check out only the Compose file on the server and print it. They run on the
* server itself, not in a helper container, so the checkout uses an absolute folder in /tmp.
*
* @return Collection<int, string>
*/
private function composeFileReadCommands(string $uuid, string $gitVersion): Collection
{
$checkoutDir = "/tmp/{$uuid}/checkout";
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir);
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand));
$workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location;
$fileList = collect([".$workdir$composeFile"]);
$composeFilePath = escapeshellarg(".$workdir$composeFile");
$fileList = collect([".{$workdir}{$this->docker_compose_location}"]);
$composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}");
$composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1;
$readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi";
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) {
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
}
$getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false);
$gitVersion = str($getGitVersion)->explode(' ')->last();
if (version_compare($gitVersion, '2.35.1', '<')) {
$isConeMode = version_compare($gitVersion, '2.35.1', '>=');
if (! $isConeMode) {
$fileList = $fileList->map(function ($file) {
$parts = explode('/', trim($file, '.'));
$paths = collect();
@@ -2190,30 +2205,39 @@ class Application extends BaseModel
return $paths;
})->flatten()->unique()->values();
$commands = collect([
"rm -rf /tmp/{$uuid}",
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'cd checkout',
'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
$readComposeFile,
]);
} else {
$commands = collect([
"rm -rf /tmp/{$uuid}",
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'cd checkout',
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
$readComposeFile,
]);
}
return collect([
"rm -rf /tmp/{$uuid}",
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
// One sh -c line: the non-root sudo parser would turn `&& if ...; then` into invalid `&& sudo if`.
'sh -c '.escapeshellarg($cloneCommand),
"cd {$checkoutDir}",
$isConeMode ? 'git sparse-checkout init --cone' : 'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
$readComposeFile,
]);
}
public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null)
{
// Use provided restore values or capture current values as fallback
$initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location;
$initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory;
if ($isInit && $this->docker_compose_raw) {
return;
}
$uuid = new_public_id();
$workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location;
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) {
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'.$this->gitFailureReason('Failed to read Git source.', (string) $gitRemoteStatus['error']));
}
$getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false);
$commands = $this->composeFileReadCommands($uuid, (string) str($getGitVersion)->explode(' ')->last());
try {
$composeFileContent = instant_remote_process($commands, $this->destination->server);
if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) {
@@ -2237,7 +2261,7 @@ class Application extends BaseModel
if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) {
throw $e;
}
throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
throw new RuntimeException('Failed to read the Docker Compose file from the repository.'.$this->gitFailureReason('Failed to read the Docker Compose file from the repository.', $e->getMessage()));
} finally {
// Cleanup only - restoration happens in catch block
$commands = collect([
+2 -1
View File
@@ -5,13 +5,14 @@ namespace App\Models;
use App\Support\DomainPortOverrides;
use App\Support\ValidationPatterns;
use App\Traits\HasRestartLimit;
use App\Traits\ReleasesManagedDnsRecords;
use Illuminate\Database\Eloquent\SoftDeletes;
use RuntimeException;
use Spatie\Url\Url;
class ApplicationPreview extends BaseModel
{
use HasRestartLimit, SoftDeletes;
use HasRestartLimit, ReleasesManagedDnsRecords, SoftDeletes;
protected $attributes = [
'max_restart_count' => 0,
+38 -5
View File
@@ -3,18 +3,43 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ManagedDnsRecord extends BaseModel
{
use HasFactory;
public const OWNERSHIP_COMMENT_PREFIX = 'managed-by: coolify';
protected $fillable = [
'team_id', 'integration_token_id', 'dns_provider_zone_id', 'resource_type', 'resource_id',
'provider_record_id', 'type', 'name', 'content',
'uuid', 'team_id', 'integration_token_id', 'dns_provider_zone_id',
'provider_record_id', 'type', 'name', 'content', 'owned',
];
protected function casts(): array
{
return [
'owned' => 'boolean',
];
}
/**
* The provider comment that proves Coolify created this record. Records without it are never modified or deleted automatically.
*/
public static function ownershipCommentFor(string $uuid): string
{
$instanceId = substr((string) (config('app.id') ?: 'default'), 0, 40);
return self::OWNERSHIP_COMMENT_PREFIX." {$instanceId}/{$uuid}";
}
public function ownershipComment(): string
{
return self::ownershipCommentFor($this->uuid);
}
public function zone(): BelongsTo
{
return $this->belongsTo(DnsProviderZone::class, 'dns_provider_zone_id');
@@ -25,8 +50,16 @@ class ManagedDnsRecord extends BaseModel
return $this->belongsTo(IntegrationToken::class);
}
public function resource(): MorphTo
public function references(): HasMany
{
return $this->morphTo();
return $this->hasMany(ManagedDnsRecordReference::class);
}
public function addReference(Model $resource): ManagedDnsRecordReference
{
return $this->references()->firstOrCreate([
'resource_type' => $resource->getMorphClass(),
'resource_id' => $resource->getKey(),
]);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* Links a managed DNS record to one resource (application, preview or service application) that uses its hostname.
*/
class ManagedDnsRecordReference extends Model
{
protected $fillable = ['managed_dns_record_id', 'resource_type', 'resource_id'];
public function record(): BelongsTo
{
return $this->belongsTo(ManagedDnsRecord::class, 'managed_dns_record_id');
}
public function resource(): MorphTo
{
return $this->morphTo();
}
}
+15
View File
@@ -1947,6 +1947,21 @@ $siteAddress {
$configRepository->disableSshMux();
}
/**
* Return the server's CA certificate, generating it first when it does not exist yet.
*/
public function ensureCaCertificate(): ?SslCertificate
{
$caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first();
if ($caCertificate) {
return $caCertificate;
}
$this->generateCaCertificate();
return $this->sslCertificates()->where('is_ca_certificate', true)->first();
}
public function generateCaCertificate()
{
try {
+2 -7
View File
@@ -2,11 +2,10 @@
namespace App\Models;
use App\Enums\ProcessStatus;
use App\Services\ContainerStatusAggregator;
use App\Support\DomainPortOverrides;
use App\Support\ResourceStartActivity;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
@@ -17,7 +16,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
#[OA\Schema(
@@ -161,10 +159,7 @@ class Service extends BaseModel
public function isStarting(): bool
{
try {
$activity = Activity::where('properties->type_uuid', $this->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
return $status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value;
return ResourceStartActivity::latestRunning($this->uuid) !== null;
} catch (\Throwable) {
return false;
}
+2 -1
View File
@@ -6,6 +6,7 @@ use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Traits\HasNoindexDomains;
use App\Traits\HasRestartLimit;
use App\Traits\ReleasesManagedDnsRecords;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -13,7 +14,7 @@ use Symfony\Component\Yaml\Yaml;
class ServiceApplication extends BaseModel
{
use HasFactory, HasNoindexDomains, HasRestartLimit, SoftDeletes;
use HasFactory, HasNoindexDomains, HasRestartLimit, ReleasesManagedDnsRecords, SoftDeletes;
protected $appends = ['url'];
+12
View File
@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum;
@@ -33,6 +35,16 @@ class AppServiceProvider extends ServiceProvider
$this->configureGitHubHttp();
$this->configureGitLabHttp();
$this->configureOidcSocialite();
$this->configureQueue();
}
/**
* Queue workers are long-running processes, so once() values (e.g. instanceSettings())
* would stay stale across jobs. Flush them before each job, like a fresh web request.
*/
private function configureQueue(): void
{
Queue::before(fn () => Once::flush());
}
private function configureCommands(): void
+146 -51
View File
@@ -2,6 +2,7 @@
namespace App\Services\Dns;
use App\Enums\ManagedDnsDeletionResult;
use App\Exceptions\DnsRecordConflictException;
use App\Models\DnsProviderZone;
use App\Models\IntegrationToken;
@@ -12,6 +13,7 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
class CloudflareDnsProvider
{
@@ -73,9 +75,11 @@ class CloudflareDnsProvider
}
/**
* @return array{id: string, type: string, name: string, content: string}|null
* All records of the given type for a hostname (several records form a round-robin set).
*
* @return array<int, array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null}>
*/
public function findRecord(DnsProviderZone $zone, string $hostname, string $type): ?array
public function findRecords(DnsProviderZone $zone, string $hostname, string $type): array
{
$hostname = strtolower(rtrim($hostname, '.'));
$response = $this->client($zone->integrationToken)->get(
@@ -85,50 +89,67 @@ class CloudflareDnsProvider
if (! $response->successful()) {
throw new RuntimeException('Cloudflare DNS records could not be checked.');
}
$remote = collect($response->json('result', []))->first();
if ($remote === null) {
return null;
}
return [
'id' => (string) ($remote['id'] ?? ''),
'type' => (string) ($remote['type'] ?? $type),
'name' => strtolower((string) ($remote['name'] ?? $hostname)),
'content' => (string) ($remote['content'] ?? ''),
];
return collect($response->json('result', []))
->filter(fn ($remote): bool => is_array($remote))
->map(fn (array $remote): array => $this->normalizeRemoteRecord($remote, $hostname, $type))
->values()
->all();
}
/**
* Creates the record with Coolify's ownership comment, or references an existing record that already has the wanted content.
* Existing records that Coolify did not create are only referenced and never marked as owned.
*/
public function createRecord(DnsProviderZone $zone, string $hostname, string $content, ?Model $resource = null): ManagedDnsRecord
{
$hostname = strtolower(rtrim($hostname, '.'));
$type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A';
$remote = $this->findRecord($zone, $hostname, $type);
if ($remote !== null) {
if ($remote['content'] === $content) {
if ($remote['id'] === '') {
throw new RuntimeException('Cloudflare DNS records could not be checked.');
}
$record = $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource);
$this->auditDnsRecord('created', $zone, $hostname, $resource);
return $record;
$type = $this->recordType($content);
$remoteRecords = $this->findRecords($zone, $hostname, $type);
$matching = collect($remoteRecords)->first(fn (array $remote): bool => $remote['content'] === $content);
if ($matching !== null) {
if ($matching['id'] === '') {
throw new RuntimeException('Cloudflare DNS records could not be checked.');
}
throw new DnsRecordConflictException($remote['id'], $remote['content'], $content);
$record = $this->trackExistingRecord($zone, $matching, $type, $hostname, $content, $resource);
$this->auditDnsRecord('adopted', $zone, $hostname, $resource);
return $record;
}
if (count($remoteRecords) > 1) {
throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare.");
}
if (count($remoteRecords) === 1) {
throw new DnsRecordConflictException($remoteRecords[0]['id'], $remoteRecords[0]['content'], $content);
}
$uuid = new_public_id();
$response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [
'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false,
'comment' => ManagedDnsRecord::ownershipCommentFor($uuid),
]);
if (! $response->successful() || ! is_string($response->json('result.id'))) {
throw new RuntimeException('Cloudflare could not create the DNS record.');
}
$record = $this->trackRecord($zone, $response->json('result.id'), $type, $hostname, $content, $resource);
$record = ManagedDnsRecord::query()->create([
'uuid' => $uuid, 'team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id,
'dns_provider_zone_id' => $zone->id, 'provider_record_id' => $response->json('result.id'),
'type' => $type, 'name' => $hostname, 'content' => $content, 'owned' => true,
]);
if ($resource !== null) {
$record->addReference($resource);
}
$this->auditDnsRecord('created', $zone, $hostname, $resource);
return $record;
}
/**
* Points an existing (conflicting) record at the new content after explicit user confirmation.
* Only the content changes: proxy status, TTL, comment and other settings stay as they are.
*/
public function replaceRecord(
DnsProviderZone $zone,
string $recordId,
@@ -138,8 +159,12 @@ class CloudflareDnsProvider
?string $expectedCurrent = null,
): ManagedDnsRecord {
$hostname = strtolower(rtrim($hostname, '.'));
$type = filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A';
$remote = $this->findRecord($zone, $hostname, $type);
$type = $this->recordType($content);
$remoteRecords = $this->findRecords($zone, $hostname, $type);
if (count($remoteRecords) > 1) {
throw new RuntimeException("Several DNS records already exist for {$hostname}. Update them in Cloudflare.");
}
$remote = $remoteRecords[0] ?? null;
if ($remote === null
|| $remote['id'] === ''
|| $remote['id'] !== $recordId
@@ -148,47 +173,117 @@ class CloudflareDnsProvider
throw new RuntimeException('The DNS conflict is no longer available. Check the record again.');
}
$response = $this->client($zone->integrationToken)->put(
$response = $this->client($zone->integrationToken)->patch(
"https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records/{$remote['id']}",
['type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false],
['content' => $content],
);
if (! $response->successful()) {
throw new RuntimeException('Cloudflare could not replace the conflicting DNS record.');
}
$record = $this->trackRecord($zone, $remote['id'], $type, $hostname, $content, $resource);
$record = $this->trackExistingRecord($zone, $remote, $type, $hostname, $content, $resource);
$this->auditDnsRecord('replaced', $zone, $hostname, $resource);
return $record;
}
public function deleteRecord(ManagedDnsRecord $record): bool
/**
* Deletes the provider record only when Coolify created it and it still carries Coolify's ownership comment and value.
* Removes the local row when the provider record is gone.
*/
public function deleteRecord(ManagedDnsRecord $record, ?Model $resource = null): ManagedDnsDeletionResult
{
if (! $record->owned) {
return ManagedDnsDeletionResult::NotOwned;
}
$record->loadMissing(['zone', 'integrationToken']);
$url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}";
$response = $this->client($record->integrationToken)->get($url);
$remote = $response->json('result');
if (! $response->successful() || ($remote['type'] ?? null) !== $record->type
|| strtolower((string) ($remote['name'] ?? '')) !== $record->name || ($remote['content'] ?? null) !== $record->content) {
return false;
}
if (! $this->client($record->integrationToken)->delete($url)->successful()) {
return false;
}
$record->delete();
$this->auditDnsRecord('deleted', $record->zone, $record->name, $record->resource);
return true;
try {
$response = $this->client($record->integrationToken)->get($url);
if ($response->status() === 404) {
return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::AlreadyGone, $resource);
}
$remote = $response->json('result');
if (! $response->successful() || ! is_array($remote)) {
return ManagedDnsDeletionResult::Failed;
}
if (($remote['type'] ?? null) !== $record->type
|| strtolower((string) ($remote['name'] ?? '')) !== $record->name
|| ($remote['content'] ?? null) !== $record->content
|| ($remote['comment'] ?? null) !== $record->ownershipComment()) {
return ManagedDnsDeletionResult::ChangedExternally;
}
$deleteResponse = $this->client($record->integrationToken)->delete($url);
if ($deleteResponse->status() === 404) {
return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::AlreadyGone, $resource);
}
if (! $deleteResponse->successful()) {
return ManagedDnsDeletionResult::Failed;
}
} catch (Throwable) {
return ManagedDnsDeletionResult::Failed;
}
return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::Deleted, $resource);
}
private function trackRecord(DnsProviderZone $zone, string $recordId, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord
private function forgetDeletedRecord(ManagedDnsRecord $record, ManagedDnsDeletionResult $result, ?Model $resource): ManagedDnsDeletionResult
{
return ManagedDnsRecord::query()->updateOrCreate(
['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $recordId],
['team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id,
'resource_type' => $resource?->getMorphClass(), 'resource_id' => $resource?->getKey(),
'type' => $type, 'name' => $name, 'content' => $content],
);
$record->delete();
if ($result === ManagedDnsDeletionResult::Deleted) {
$this->auditDnsRecord('deleted', $record->zone, $record->name, $resource);
}
return $result;
}
/**
* Tracks a record that already exists in the provider. New rows are never owned; an owned row whose
* provider comment no longer matches loses its ownership.
*
* @param array{id: string, comment: string|null} $remote
*/
private function trackExistingRecord(DnsProviderZone $zone, array $remote, string $type, string $name, string $content, ?Model $resource): ManagedDnsRecord
{
$record = ManagedDnsRecord::query()->firstOrNew(['dns_provider_zone_id' => $zone->id, 'provider_record_id' => $remote['id']]);
$record->fill([
'team_id' => $zone->integrationToken->team_id, 'integration_token_id' => $zone->integration_token_id,
'type' => $type, 'name' => $name, 'content' => $content,
]);
if (! $record->exists || $remote['comment'] !== $record->ownershipComment()) {
$record->owned = false;
}
$record->save();
if ($resource !== null) {
$record->addReference($resource);
}
return $record;
}
/**
* @param array<string, mixed> $remote
* @return array{id: string, type: string, name: string, content: string, proxied: bool|null, ttl: int|null, comment: string|null}
*/
private function normalizeRemoteRecord(array $remote, string $hostname, string $type): array
{
return [
'id' => (string) ($remote['id'] ?? ''),
'type' => (string) ($remote['type'] ?? $type),
'name' => strtolower((string) ($remote['name'] ?? $hostname)),
'content' => (string) ($remote['content'] ?? ''),
'proxied' => isset($remote['proxied']) ? (bool) $remote['proxied'] : null,
'ttl' => isset($remote['ttl']) ? (int) $remote['ttl'] : null,
'comment' => isset($remote['comment']) && is_string($remote['comment']) ? $remote['comment'] : null,
];
}
private function recordType(string $content): string
{
return filter_var($content, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'AAAA' : 'A';
}
private function auditDnsRecord(string $action, DnsProviderZone $zone, string $hostname, ?Model $resource): void
@@ -0,0 +1,271 @@
<?php
namespace App\Services\Dns;
use App\Enums\ManagedDnsDeletionResult;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ManagedDnsRecord;
use App\Models\ManagedDnsRecordReference;
use App\Models\ServiceApplication;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Releases resource references to managed DNS records and deletes a provider record only when
* Coolify owns it, no resource references it and no application, preview or service application
* (in any team) still uses its hostname.
*/
class ManagedDnsRecordCleanup
{
public function __construct(private CloudflareDnsProvider $provider) {}
/**
* Releases the resource's reference after one of its URLs was removed.
* Nothing is released while the resource still uses the hostname through another URL.
*/
public function releaseHostname(Model $resource, string $hostname, int $teamId, bool $deleteRecord = true): ?ManagedDnsDeletionResult
{
$hostname = $this->normalizeHostname($hostname);
$current = $resource->fresh() ?? $resource;
if (in_array($hostname, $this->hostnamesOf($current), true)) {
return null;
}
$keys = [[$resource->getMorphClass(), $resource->getKey()]];
$records = ManagedDnsRecord::query()
->where('team_id', $teamId)
->where('name', $hostname)
->whereHas('references', fn (Builder $query) => $this->whereResourceKeys($query, $keys))
->get();
$worst = null;
foreach ($records as $record) {
$result = $this->release($record, $keys, $deleteRecord, $resource);
if ($result === ManagedDnsDeletionResult::ChangedExternally || $result === ManagedDnsDeletionResult::Failed) {
$worst = $worst === ManagedDnsDeletionResult::Failed ? $worst : $result;
}
}
return $worst;
}
/**
* Releases every reference of a deleted resource (and, for applications, of their previews).
* Never throws; returns false when a provider call failed and the release should be retried.
*/
public function releaseResource(string $resourceType, int|string $resourceId): bool
{
$keys = $this->resourceKeys($resourceType, $resourceId);
$records = ManagedDnsRecord::query()
->whereHas('references', fn (Builder $query) => $this->whereResourceKeys($query, $keys))
->get();
$successful = true;
foreach ($records as $record) {
try {
if ($this->release($record, $keys, true) === ManagedDnsDeletionResult::Failed) {
$successful = false;
}
} catch (Throwable $e) {
$successful = false;
Log::warning('Managed DNS record cleanup failed after resource deletion.', [
'managed_dns_record_id' => $record->id,
'hostname' => $record->name,
'error' => $e->getMessage(),
]);
}
}
return $successful;
}
/**
* @param array<int, array{0: string, 1: int|string}> $releasingKeys
*/
public function release(ManagedDnsRecord $record, array $releasingKeys, bool $deleteRecord, ?Model $contextResource = null): ?ManagedDnsDeletionResult
{
$releasing = collect($releasingKeys)->map(fn (array $key): string => $key[0].'|'.$key[1]);
$otherReferences = $record->references()->get()
->reject(fn (ManagedDnsRecordReference $reference): bool => $releasing->contains($reference->resource_type.'|'.$reference->resource_id));
$liveReferences = $otherReferences->filter(function (ManagedDnsRecordReference $reference): bool {
if ($reference->resource !== null) {
return true;
}
$reference->delete();
return false;
});
if ($liveReferences->isNotEmpty()) {
$this->deleteReferences($record, $releasingKeys);
return null;
}
$users = $this->resourcesUsingHostname($record->name, $releasing->all());
if ($users->isNotEmpty()) {
$sameTeamUsers = $users->filter(fn (Model $user): bool => $this->teamIdOf($user) === (int) $record->team_id);
if ($sameTeamUsers->isEmpty()) {
$this->forget($record, 'hostname_used_by_another_team');
return null;
}
$sameTeamUsers->each(fn (Model $user) => $record->addReference($user));
$this->deleteReferences($record, $releasingKeys);
return null;
}
if (! $record->owned || ! $deleteRecord) {
$record->delete();
return $record->owned ? null : ManagedDnsDeletionResult::NotOwned;
}
$result = $this->provider->deleteRecord($record, $contextResource);
if ($result === ManagedDnsDeletionResult::ChangedExternally) {
$this->forget($record, 'remote_record_changed');
} elseif ($result === ManagedDnsDeletionResult::Failed) {
$this->auditSkipped($record, 'provider_unavailable');
}
return $result;
}
/**
* Live applications, previews and service applications (any team) that use the hostname, except the given resource keys.
*
* @param array<int, string> $exceptKeys "morph-class|id" keys
* @return Collection<int, Model>
*/
public function resourcesUsingHostname(string $hostname, array $exceptKeys = []): Collection
{
$hostname = $this->normalizeHostname($hostname);
$pattern = '%'.$hostname.'%';
$matchesFqdnOrCompose = fn (Builder $query) => $query->where(fn (Builder $query) => $query
->whereRaw('LOWER(fqdn) LIKE ?', [$pattern])
->orWhereRaw('LOWER(docker_compose_domains) LIKE ?', [$pattern]));
return collect()
->concat(Application::query()->where($matchesFqdnOrCompose)->get())
->concat(ApplicationPreview::query()->where($matchesFqdnOrCompose)->get())
->concat(ServiceApplication::query()->whereRaw('LOWER(fqdn) LIKE ?', [$pattern])->get())
->reject(fn (Model $resource): bool => in_array($resource->getMorphClass().'|'.$resource->getKey(), $exceptKeys, true))
->filter(fn (Model $resource): bool => in_array($hostname, $this->hostnamesOf($resource), true))
->values();
}
/**
* @return array<int, string>
*/
public function hostnamesOf(Model $resource): array
{
$attributes = $resource->getAttributes();
$values = $this->splitUrls($attributes['fqdn'] ?? null);
$composeDomains = json_decode((string) ($attributes['docker_compose_domains'] ?? ''), true);
if (is_array($composeDomains)) {
foreach ($composeDomains as $service) {
$domain = is_array($service) ? ($service['domain'] ?? null) : null;
array_push($values, ...$this->splitUrls(is_string($domain) ? $domain : null));
}
}
return collect($values)
->map(fn (string $url) => parse_url(str_contains($url, '://') ? $url : 'http://'.$url, PHP_URL_HOST))
->filter(fn ($host): bool => is_string($host) && $host !== '')
->map(fn (string $host): string => $this->normalizeHostname($host))
->unique()
->values()
->all();
}
private function teamIdOf(Model $resource): ?int
{
$teamId = match (true) {
$resource instanceof ApplicationPreview => data_get($resource, 'application.environment.project.team_id'),
$resource instanceof ServiceApplication => data_get($resource, 'service.environment.project.team_id'),
default => data_get($resource, 'environment.project.team_id'),
};
return $teamId === null ? null : (int) $teamId;
}
/**
* @return array<int, array{0: string, 1: int|string}>
*/
private function resourceKeys(string $resourceType, int|string $resourceId): array
{
$keys = [[$resourceType, $resourceId]];
if ($resourceType === (new Application)->getMorphClass()) {
$previewType = (new ApplicationPreview)->getMorphClass();
ApplicationPreview::withTrashed()->where('application_id', $resourceId)->pluck('id')
->each(function ($previewId) use (&$keys, $previewType): void {
$keys[] = [$previewType, $previewId];
});
}
return $keys;
}
/**
* @param array<int, array{0: string, 1: int|string}> $keys
*/
private function whereResourceKeys(Builder $query, array $keys): Builder
{
return $query->where(function (Builder $query) use ($keys): void {
foreach ($keys as [$type, $id]) {
$query->orWhere(fn (Builder $query) => $query->where('resource_type', $type)->where('resource_id', $id));
}
});
}
/**
* @param array<int, array{0: string, 1: int|string}> $keys
*/
private function deleteReferences(ManagedDnsRecord $record, array $keys): void
{
$this->whereResourceKeys($record->references()->getQuery(), $keys)->delete();
}
/**
* Stops tracking the record without touching the provider.
*/
private function forget(ManagedDnsRecord $record, string $reason): void
{
$record->delete();
$this->auditSkipped($record, $reason);
}
private function auditSkipped(ManagedDnsRecord $record, string $reason): void
{
$source = auth()->check() ? 'ui' : 'system';
auditLog("{$source}.dns_record.delete_skipped", [
'team_id' => $record->team_id,
'hostname' => $record->name,
'provider' => 'cloudflare',
'reason' => $reason,
], 'warning');
}
/**
* @return array<int, string>
*/
private function splitUrls(?string $value): array
{
if (blank($value)) {
return [];
}
return collect(explode(',', $value))->map(fn ($url) => trim((string) $url))->filter()->values()->all();
}
private function normalizeHostname(string $hostname): string
{
return strtolower(rtrim($hostname, '.'));
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Services;
use RuntimeException;
class TraefikAcmeService
{
/**
* @return array<int, array{id: string, resolver: string, main_domain: string, sans: array<int, string>, store: ?string, expires_at: ?string}>
*/
public function certificates(string $contents): array
{
$data = $this->decode($contents);
$certificates = [];
foreach ($data as $resolver => $resolverData) {
if (! is_string($resolver) || ! is_array($resolverData)) {
continue;
}
$resolverCertificates = data_get($resolverData, 'Certificates', []);
if (! is_array($resolverCertificates)) {
continue;
}
foreach ($resolverCertificates as $certificate) {
if (! is_array($certificate)) {
continue;
}
$mainDomain = data_get($certificate, 'domain.main');
if (! is_string($mainDomain) || $mainDomain === '') {
continue;
}
$certificateSans = data_get($certificate, 'domain.sans', []);
$sans = array_values(array_filter(
is_array($certificateSans) ? $certificateSans : [],
fn (mixed $domain): bool => is_string($domain) && $domain !== '',
));
$certificates[] = [
'id' => $this->certificateId($resolver, $certificate),
'resolver' => $resolver,
'main_domain' => $mainDomain,
'sans' => $sans,
'store' => is_string(data_get($certificate, 'Store')) ? data_get($certificate, 'Store') : null,
'expires_at' => $this->expirationDate(data_get($certificate, 'certificate')),
];
}
}
return $certificates;
}
public function deleteCertificate(string $contents, string $resolver, string $certificateId): string
{
$data = $this->decode($contents);
$certificates = $data[$resolver]['Certificates'] ?? null;
if (! is_array($certificates)) {
throw new RuntimeException('The selected certificate could not be found.');
}
$remaining = [];
$deleted = false;
foreach ($certificates as $certificate) {
if (! $deleted && is_array($certificate) && hash_equals($this->certificateId($resolver, $certificate), $certificateId)) {
$deleted = true;
continue;
}
$remaining[] = $certificate;
}
if (! $deleted) {
throw new RuntimeException('The selected certificate could not be found.');
}
$data[$resolver]['Certificates'] = $remaining;
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL;
}
/** @return array<string, mixed> */
private function decode(string $contents): array
{
try {
$data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new RuntimeException('The Traefik ACME file contains invalid JSON.', previous: $exception);
}
if (! is_array($data)) {
throw new RuntimeException('The Traefik ACME file contains invalid JSON.');
}
return $data;
}
/** @param array<string, mixed> $certificate */
private function certificateId(string $resolver, array $certificate): string
{
return hash('sha256', $resolver."\0".json_encode($certificate, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
}
private function expirationDate(mixed $encodedCertificate): ?string
{
if (! is_string($encodedCertificate) || $encodedCertificate === '') {
return null;
}
$der = base64_decode($encodedCertificate, true);
if ($der === false) {
return null;
}
$pem = "-----BEGIN CERTIFICATE-----\n".chunk_split(base64_encode($der), 64, "\n")."-----END CERTIFICATE-----\n";
$details = openssl_x509_parse($pem);
$expiresAt = data_get($details, 'validTo_time_t');
return is_int($expiresAt) ? gmdate('Y-m-d H:i:s', $expiresAt) : null;
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
namespace App\Support;
use App\Enums\ProcessStatus;
use App\Models\Service;
use Illuminate\Support\Collection;
use Spatie\Activitylog\Models\Activity;
/**
* State of the activity log rows that track database and service start/restart runs.
*
* A killed worker, a Coolify restart or a lost queue can leave such an activity queued or
* in progress forever. Activities that have not progressed for longer than their job can
* possibly run are treated as stale, so they no longer block Start/Restart in the UI.
* Command output is saved to the activity as it arrives, so `updated_at` is the heartbeat.
*/
class ResourceStartActivity
{
public const DATABASE_START_OPERATION = 'database-start';
/**
* A start that is still waiting in the queue after this long is treated as lost.
*/
public const QUEUED_STALE_AFTER_SECONDS = 600;
/**
* DatabaseStartJob and CoolifyTask time out after 600 seconds; allow a margin on top.
*/
public const IN_PROGRESS_STALE_AFTER_SECONDS = 900;
public const INTERRUPTED_MESSAGE = 'Interrupted by a Coolify restart.';
private const ACTIVE_STATUSES = [
ProcessStatus::QUEUED->value,
ProcessStatus::IN_PROGRESS->value,
];
/**
* The latest activity for a resource, if it is queued or in progress and not stale.
*/
public static function latestRunning(string $typeUuid): ?Activity
{
$activity = Activity::query()
->where('properties->type_uuid', $typeUuid)
->latest()
->first();
return self::isRunning($activity) ? $activity : null;
}
public static function isRunning(?Activity $activity): bool
{
if (! $activity || ! in_array(data_get($activity, 'properties.status'), self::ACTIVE_STATUSES, true)) {
return false;
}
return ! self::isStale($activity);
}
public static function isStale(Activity $activity): bool
{
$lastProgressAt = $activity->updated_at ?? $activity->created_at;
if (! $lastProgressAt) {
return true;
}
$staleAfterSeconds = data_get($activity, 'properties.status') === ProcessStatus::QUEUED->value
? self::QUEUED_STALE_AFTER_SECONDS
: self::IN_PROGRESS_STALE_AFTER_SECONDS;
return $lastProgressAt->lte(now()->subSeconds($staleAfterSeconds));
}
/**
* Mark an activity as failed, append the reason to its log and stop log polling.
*/
public static function markFailed(Activity $activity, string $message): void
{
$properties = [
'status' => ProcessStatus::ERROR->value,
'error' => $message,
'failed_at' => now()->toIso8601String(),
];
if ($activity->properties->get('exitCode') === null) {
$properties['exitCode'] = 1;
}
$activity->properties = $activity->properties->merge($properties);
$activity->description = self::appendOutput($activity->description, $message);
$activity->save();
}
/**
* Fail database and service start activities left queued or in progress by a restart.
* Runs during app:init, before any queue worker is started.
*/
public static function failInterrupted(): int
{
$activities = Activity::query()
->whereIn('properties->status', self::ACTIVE_STATUSES)
->get();
$serviceUuids = self::existingServiceUuids($activities);
$interrupted = $activities->filter(
fn (Activity $activity): bool => data_get($activity, 'properties.operation') === self::DATABASE_START_OPERATION
|| $serviceUuids->contains(data_get($activity, 'properties.type_uuid'))
);
$interrupted->each(fn (Activity $activity) => self::markFailed($activity, self::INTERRUPTED_MESSAGE));
return $interrupted->count();
}
/**
* @param Collection<int, Activity> $activities
* @return Collection<int, string>
*/
private static function existingServiceUuids(Collection $activities): Collection
{
$typeUuids = $activities
->map(fn (Activity $activity) => data_get($activity, 'properties.type_uuid'))
->filter(fn ($uuid): bool => is_string($uuid) && $uuid !== '')
->unique()
->values();
if ($typeUuids->isEmpty()) {
return collect();
}
return $typeUuids
->chunk(500)
->flatMap(fn (Collection $chunk) => Service::query()->whereIn('uuid', $chunk->all())->pluck('uuid'))
->values();
}
private static function appendOutput(?string $description, string $message): string
{
try {
$entries = json_decode($description ?: '[]', true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
$entries = [];
}
if (! is_array($entries)) {
$entries = [];
}
$lastOrder = collect($entries)->max('order') ?? 0;
$entries[] = [
'type' => 'stderr',
'output' => "\n{$message}\n",
'timestamp' => hrtime(true),
'batch' => 1,
'order' => $lastOrder + 1,
];
return json_encode($entries, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Traits;
use App\Jobs\ReleaseManagedDnsRecordsJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ManagedDnsRecordReference;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Releases a resource's managed DNS references after it is deleted (soft or force delete).
* The cleanup runs in a queued job after commit so provider errors never block the deletion.
*/
trait ReleasesManagedDnsRecords
{
public static function bootReleasesManagedDnsRecords(): void
{
static::deleted(function (Model $resource): void {
try {
if (! self::hasManagedDnsReferences($resource)) {
return;
}
ReleaseManagedDnsRecordsJob::dispatch($resource->getMorphClass(), $resource->getKey())->afterCommit();
} catch (Throwable $e) {
Log::warning('Could not queue managed DNS cleanup for a deleted resource.', [
'resource_type' => $resource->getMorphClass(),
'resource_id' => $resource->getKey(),
'error' => $e->getMessage(),
]);
}
});
}
private static function hasManagedDnsReferences(Model $resource): bool
{
$query = ManagedDnsRecordReference::query()->where(fn ($query) => $query
->where('resource_type', $resource->getMorphClass())
->where('resource_id', $resource->getKey()));
if ($resource instanceof Application) {
$query->orWhere(fn ($query) => $query
->where('resource_type', (new ApplicationPreview)->getMorphClass())
->whereIn('resource_id', ApplicationPreview::withTrashed()->where('application_id', $resource->getKey())->select('id')));
}
return $query->exists();
}
}
@@ -17,6 +17,15 @@ class ManagedDnsRecordFactory extends Factory
'integration_token_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integration_token_id,
'team_id' => fn (array $attributes) => DnsProviderZone::query()->findOrFail($attributes['dns_provider_zone_id'])->integrationToken->team_id,
'provider_record_id' => fake()->uuid(), 'type' => 'A', 'name' => fake()->domainName(), 'content' => fake()->ipv4(),
'owned' => false,
];
}
/**
* A record Coolify created itself (carries the ownership comment in the provider).
*/
public function owned(): static
{
return $this->state(fn (array $attributes) => ['owned' => true]);
}
}
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Existing rows stay not owned: before this change Coolify tracked adopted
* (hand-made) records exactly like the records it created and wrote no
* ownership comment, so neither can be told apart safely.
*/
public function up(): void
{
Schema::table('managed_dns_records', function (Blueprint $table) {
$table->boolean('owned')->default(false)->after('content');
});
}
public function down(): void
{
Schema::table('managed_dns_records', function (Blueprint $table) {
$table->dropColumn('owned');
});
}
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('managed_dns_record_references', function (Blueprint $table) {
$table->id();
$table->foreignId('managed_dns_record_id')->constrained()->cascadeOnDelete();
$table->morphs('resource');
$table->timestamps();
$table->unique(['managed_dns_record_id', 'resource_type', 'resource_id'], 'managed_dns_record_references_unique');
});
DB::table('managed_dns_records')
->whereNotNull('resource_type')
->whereNotNull('resource_id')
->orderBy('id')
->chunkById(500, function ($records): void {
DB::table('managed_dns_record_references')->insertOrIgnore($records->map(fn ($record): array => [
'managed_dns_record_id' => $record->id,
'resource_type' => $record->resource_type,
'resource_id' => $record->resource_id,
'created_at' => now(),
'updated_at' => now(),
])->all());
});
Schema::table('managed_dns_records', function (Blueprint $table) {
$table->dropMorphs('resource');
});
}
public function down(): void
{
Schema::table('managed_dns_records', function (Blueprint $table) {
$table->nullableMorphs('resource');
});
DB::table('managed_dns_record_references')->orderBy('id')->chunkById(500, function ($references): void {
foreach ($references as $reference) {
DB::table('managed_dns_records')
->where('id', $reference->managed_dns_record_id)
->whereNull('resource_type')
->update(['resource_type' => $reference->resource_type, 'resource_id' => $reference->resource_id]);
}
});
Schema::dropIfExists('managed_dns_record_references');
}
};
+270 -107
View File
@@ -26,16 +26,221 @@ CURRENT_USER=$USER
if [ $EUID != 0 ]; then
echo "Please run this script as root or with sudo"
exit
exit 1
fi
echo ""
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
chown -R 9999:root /data/coolify
chmod -R 700 /data/coolify
INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
# Terminal UI: the terminal only shows the step list, everything else goes to the log file
TOTAL_STEPS=9
UI_STEP=0
UI_STEP_TITLE=""
UI_STEP_DETAIL=""
UI_STEP_STARTED=0
UI_SPINNER_PID=""
UI_WARNINGS=()
if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ]; then
UI_TTY=true
else
UI_TTY=false
fi
if [ "$UI_TTY" = true ] && [ -z "${NO_COLOR:-}" ]; then
C_RESET=$'\033[0m'
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
C_PURPLE=$'\033[38;5;135m'
C_GREEN=$'\033[32m'
C_RED=$'\033[31m'
C_YELLOW=$'\033[33m'
else
C_RESET="" C_BOLD="" C_DIM="" C_PURPLE="" C_GREEN="" C_RED="" C_YELLOW=""
fi
UI_WIDTH=$(tput cols 2>/dev/null || true)
if ! [[ $UI_WIDTH =~ ^[0-9]+$ ]]; then
UI_WIDTH=80
fi
UI_WIDTH=$((UI_WIDTH > 100 ? 100 : UI_WIDTH < 60 ? 60 : UI_WIDTH))
# Current time in milliseconds
ui_now() {
if [ -n "${EPOCHREALTIME:-}" ]; then
local now=${EPOCHREALTIME/[.,]/}
echo $((now / 1000))
else
echo $(($(date +%s) * 1000))
fi
}
ui_duration() {
local ms=$1
if [ "$ms" -lt 60000 ]; then
printf '%d.%ds' $((ms / 1000)) $((ms % 1000 / 100))
else
printf '%dm %ds' $((ms / 60000)) $((ms % 60000 / 1000))
fi
}
ui_repeat() {
local out="" i
for ((i = 0; i < $2; i++)); do
out+="$1"
done
printf '%s' "$out"
}
# Prints the current step line without the trailing status symbol
ui_render_step() {
local right="$1"
local title="$UI_STEP_TITLE"
local detail="$UI_STEP_DETAIL"
local plain_detail="${detail//·/.}"
local left=$((7 + ${#title}))
local right_width=$((${#right} + 2))
if [ -n "$detail" ] && [ $((left + 2 + ${#plain_detail} + right_width + 2)) -le "$UI_WIDTH" ]; then
left=$((left + 2 + ${#plain_detail}))
else
detail=""
fi
local pad=$((UI_WIDTH - left - right_width))
if [ "$pad" -lt 2 ]; then
pad=2
fi
printf ' %s%-5s%s%s%s%s' "$C_DIM" "${UI_STEP}/${TOTAL_STEPS}" "$C_RESET" "$C_BOLD" "$title" "$C_RESET"
if [ -n "$detail" ]; then
printf ' %s%s%s' "$C_DIM" "$detail" "$C_RESET"
fi
printf '%*s%s%s%s ' "$pad" "" "$C_DIM" "$right" "$C_RESET"
}
ui_bar() {
local percent=$1 label=$2
local cells=$((UI_WIDTH - 30 > 40 ? 40 : UI_WIDTH - 30))
local filled=$((percent * cells / 100))
printf ' %s%s%s%s%s %s%3d%%%s %s%s%s' \
"$C_PURPLE" "$(ui_repeat ■ "$filled")" "$C_DIM" "$(ui_repeat ■ $((cells - filled)))" "$C_RESET" \
"$C_BOLD" "$percent" "$C_RESET" "$C_DIM" "$label" "$C_RESET"
}
# Runs in the background and animates the current step line and the progress bar below it
ui_spinner() {
set +x
local frames=(◜ ◠ ◝ ◞ ◡ ◟) i=0 stop=false
local line bar
trap 'stop=true' TERM
line=$(ui_render_step "")
bar=$(ui_bar $(((UI_STEP - 1) * 100 / TOTAL_STEPS)) "${UI_STEP_TITLE,,}…")
while [ "$stop" = false ]; do
printf '\r\033[2K%s%s%s%s\n\033[2K%s\033[1A\r' "$line" "$C_PURPLE" "${frames[i]}" "$C_RESET" "$bar" >&3
i=$(((i + 1) % ${#frames[@]}))
sleep 0.12
done
}
ui_stop_spinner() {
if [ -n "$UI_SPINNER_PID" ]; then
kill "$UI_SPINNER_PID" 2>/dev/null || true
wait "$UI_SPINNER_PID" 2>/dev/null || true
UI_SPINNER_PID=""
fi
}
# Shows a warning below the current step (or right away when no step is running)
warn() {
echo "WARNING: $*"
UI_WARNINGS+=("$*")
}
ui_flush_warnings() {
local warning
for warning in "${UI_WARNINGS[@]}"; do
printf ' %s! %s%s\n' "$C_YELLOW" "$warning" "$C_RESET" >&3
done
UI_WARNINGS=()
}
step_start() {
UI_STEP=$((UI_STEP + 1))
UI_STEP_TITLE="$1"
UI_STEP_DETAIL="${2:-}"
UI_STEP_STARTED=$(ui_now)
log_section "Step ${UI_STEP}/${TOTAL_STEPS}: $1"
if [ "$UI_TTY" = true ]; then
ui_spinner &
UI_SPINNER_PID=$!
fi
}
# Usage: step_done [detail] - optionally replaces the detail text shown next to the step title
step_done() {
if [ -n "${1:-}" ]; then
UI_STEP_DETAIL="$1"
fi
ui_stop_spinner
local elapsed
elapsed=$(ui_duration $(($(ui_now) - UI_STEP_STARTED)))
log "Step ${UI_STEP}/${TOTAL_STEPS} completed in ${elapsed}"
if [ "$UI_TTY" = true ]; then
printf '\r\033[2K' >&3
fi
printf '%s%s✓%s\n' "$(ui_render_step "$elapsed")" "$C_GREEN" "$C_RESET" >&3
if [ "$UI_TTY" = true ]; then
printf '\033[2K' >&3
fi
ui_flush_warnings
UI_STEP_TITLE=""
}
ui_on_exit() {
local code=$?
ui_stop_spinner
if [ "$code" -ne 0 ]; then
if [ "$UI_TTY" = true ]; then
printf '\r\033[2K' >&3
fi
if [ -n "$UI_STEP_TITLE" ]; then
printf '%s%s✗%s\n' "$(ui_render_step "$(ui_duration $(($(ui_now) - UI_STEP_STARTED)))")" "$C_RED" "$C_RESET" >&3
fi
if [ "$UI_TTY" = true ]; then
printf '\033[2K' >&3
fi
ui_flush_warnings
printf '\n %s%sInstallation failed.%s Last lines of the log:\n\n' "$C_BOLD" "$C_RED" "$C_RESET" >&3
tail -n 15 "$INSTALLATION_LOG_WITH_DATE" 2>/dev/null | sed 's/^/ /' >&3
printf '\n %sFull log: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
fi
if [ "$UI_TTY" = true ]; then
printf '\033[?25h' >&3
fi
}
# fd 3 is the terminal, stdout and stderr go to the log file
exec 3>&1
exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1
trap ui_on_exit EXIT
if [ "$UI_TTY" = true ]; then
printf '\033[?25l' >&3
fi
printf '\n %sWelcome to Coolify Installer!%s\n' "$C_BOLD" "$C_RESET" >&3
printf ' %sThis script will install everything for you. Sit back and relax.%s\n' "$C_DIM" "$C_RESET" >&3
printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
UI_INSTALL_STARTED=$(ui_now)
echo "=========================================="
echo " Coolify Installation - ${DATE}"
echo "=========================================="
echo ""
echo "Welcome to Coolify Installer!"
echo "This script will install everything for you. Sit back and relax."
echo "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh"
# Predefined root user
@@ -157,9 +362,7 @@ if [ -f /etc/docker/daemon.json ]; then
if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ]; then
echo "Force override enabled - network pool will be updated with $DOCKER_ADDRESS_POOL_BASE/$DOCKER_ADDRESS_POOL_SIZE."
else
echo "Custom pool provided but force override not enabled - using existing configuration."
echo "To force override, set DOCKER_POOL_FORCE_OVERRIDE=true"
echo "This won't change the existing docker networks, only the pool configuration for the newly created networks."
warn "Custom Docker network pool ignored, using the existing $EXISTING_POOL_BASE/$EXISTING_POOL_SIZE. Set DOCKER_POOL_FORCE_OVERRIDE=true to override it (existing networks are not changed)."
DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE"
DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE"
DOCKER_POOL_BASE_PROVIDED=false
@@ -172,7 +375,7 @@ fi
# Validate Docker address pool configuration
if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
echo "Warning: Invalid network pool base format: $DOCKER_ADDRESS_POOL_BASE"
warn "Invalid Docker network pool base: $DOCKER_ADDRESS_POOL_BASE"
if [ "$EXISTING_POOL_CONFIGURED" = true ]; then
echo "Using existing configuration: $EXISTING_POOL_BASE"
DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE"
@@ -183,7 +386,7 @@ if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]];
fi
if ! [[ $DOCKER_ADDRESS_POOL_SIZE =~ ^[0-9]+$ ]] || [ "$DOCKER_ADDRESS_POOL_SIZE" -lt 16 ] || [ "$DOCKER_ADDRESS_POOL_SIZE" -gt 28 ]; then
echo "Warning: Invalid network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)"
warn "Invalid Docker network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)"
if [ "$EXISTING_POOL_CONFIGURED" = true ]; then
echo "Using existing configuration: $EXISTING_POOL_SIZE"
DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE"
@@ -201,52 +404,20 @@ WARNING_SPACE=false
if [ "$TOTAL_SPACE" -lt "$REQUIRED_TOTAL_SPACE" ]; then
WARNING_SPACE=true
cat <<EOF
WARNING: Insufficient total disk space!
Total disk space: ${TOTAL_SPACE}GB
Required disk space: ${REQUIRED_TOTAL_SPACE}GB
==================
EOF
warn "Insufficient total disk space: ${TOTAL_SPACE}GB (required: ${REQUIRED_TOTAL_SPACE}GB)"
fi
if [ "$AVAILABLE_SPACE" -lt "$REQUIRED_AVAILABLE_SPACE" ]; then
cat <<EOF
WARNING: Insufficient available disk space!
Available disk space: ${AVAILABLE_SPACE}GB
Required available space: ${REQUIRED_AVAILABLE_SPACE}GB
==================
EOF
warn "Insufficient available disk space: ${AVAILABLE_SPACE}GB (required: ${REQUIRED_AVAILABLE_SPACE}GB)"
WARNING_SPACE=true
fi
if [ "$WARNING_SPACE" = true ]; then
ui_flush_warnings
echo "Sleeping for 5 seconds."
sleep 5
fi
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
chown -R 9999:root /data/coolify
chmod -R 700 /data/coolify
INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
exec > >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1
getAJoke() {
JOKES=$(curl -s --max-time 2 "https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&type=single" || true)
if [ "$JOKES" != "" ]; then
echo -e " - Until then, here's a joke for you:\n"
echo -e "$JOKES\n"
fi
}
# Helper function to log with timestamp
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
@@ -329,7 +500,7 @@ case "$OS_TYPE" in
arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | amzn | alpine | postmarketos | tencentos) ;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now."
exit
exit 1
;;
esac
@@ -348,10 +519,9 @@ echo "| Helper | $LATEST_HELPER_VERSION"
echo "| Docker Pool | $DOCKER_ADDRESS_POOL_BASE (size $DOCKER_ADDRESS_POOL_SIZE)"
echo "| Registry URL | $REGISTRY_URL"
echo "---------------------------------------------"
echo ""
log_section "Step 1/9: Installing required packages"
echo "1/9 Installing required packages (curl, wget, git, jq, openssl)..."
ui_flush_warnings
step_start "Installing required packages" "curl wget git jq openssl"
# Track if apt-get update was run to avoid redundant calls later
APT_UPDATED=false
@@ -359,6 +529,7 @@ APT_UPDATED=false
if all_packages_installed; then
log "All required packages already installed, skipping installation"
echo " - All required packages already installed."
UI_STEP_DETAIL="already installed"
else
case "$OS_TYPE" in
arch)
@@ -393,15 +564,14 @@ else
;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now."
exit
exit 1
;;
esac
log "Required packages installed successfully"
fi
echo " Done."
step_done
log_section "Step 2/9: Checking OpenSSH server configuration"
echo "2/9 Checking OpenSSH server configuration..."
step_start "Checking OpenSSH server configuration"
# Detect OpenSSH server
SSH_DETECTED=false
@@ -476,8 +646,7 @@ SSH_PERMIT_ROOT_LOGIN=$(sshd -T | grep -i "permitrootlogin" | awk '{print $2}')
if [ "$SSH_PERMIT_ROOT_LOGIN" = "yes" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "without-password" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "prohibit-password" ]; then
echo " - SSH PermitRootLogin is enabled."
else
echo " - SSH PermitRootLogin is disabled."
echo " If you have problems with SSH, please read this: https://coolify.io/docs/knowledge-base/server/openssh"
warn "SSH PermitRootLogin is disabled. If you have problems with SSH, read https://coolify.io/docs/knowledge-base/server/openssh"
fi
# Detect if docker is installed via snap
@@ -542,11 +711,11 @@ install_docker_from_rhel_repo() {
systemctl --now enable docker
}
log_section "Step 3/9: Checking Docker installation"
echo "3/9 Checking Docker installation..."
step_done
if ! [ -x "$(command -v docker)" ]; then
step_start "Installing Docker Engine"
echo " - Docker is not installed. Installing Docker. It may take a while."
getAJoke
case "$OS_TYPE" in
"alpine" | "postmarketos")
apk add docker docker-cli-compose >/dev/null 2>&1
@@ -616,6 +785,7 @@ if ! [ -x "$(command -v docker)" ]; then
esac
echo " - Docker installed successfully."
else
step_start "Checking Docker Engine"
echo " - Docker is installed."
fi
@@ -623,7 +793,7 @@ fi
MIN_DOCKER_VERSION=24
INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
warn "Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
@@ -631,9 +801,10 @@ elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
else
echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
fi
DOCKER_SERVER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null || true)
step_done "${DOCKER_SERVER_VERSION:+v$DOCKER_SERVER_VERSION}"
log_section "Step 4/9: Checking Docker configuration"
echo "4/9 Checking Docker configuration..."
step_start "Configuring Docker daemon" "log rotation · address pools"
echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}"
echo " - To override existing configuration: DOCKER_POOL_FORCE_OVERRIDE=true"
@@ -762,8 +933,9 @@ else
fi
fi
log_section "Step 5/9: Downloading required files from CDN"
echo "5/9 Downloading required files from CDN..."
step_done
step_start "Downloading required files from CDN"
log "Downloading configuration files in parallel..."
# Download files in parallel for faster installation
@@ -793,10 +965,9 @@ fi
chmod +x /data/coolify/source/upgrade.sh /data/coolify/source/upgrade-postgres.sh
log "All configuration files downloaded successfully"
echo " Done."
step_done
log_section "Step 6/9: Setting up environment variable file"
echo "6/9 Setting up environment variable file..."
step_start "Setting up environment variables"
if [ -f "$ENV_FILE" ]; then
# If .env exists, create backup
@@ -812,10 +983,6 @@ else
cp "/data/coolify/source/.env.production" "$ENV_FILE"
fi
log "Environment file setup completed"
echo " Done."
log_section "Step 7/9: Checking and updating environment variables"
echo "7/9 Checking and updating environment variables..."
update_env_var() {
local key="$1"
@@ -877,10 +1044,10 @@ else
fi
fi
log "Environment variables check completed"
echo " Done."
step_done
log_section "Step 8/9: Checking SSH key for localhost access"
echo "8/9 Checking SSH key for localhost access..."
step_start "Checking SSH key for localhost access"
SSH_KEY_DETAIL="existing key"
if [ ! -f ~/.ssh/authorized_keys ]; then
mkdir -p ~/.ssh
chmod 700 ~/.ssh
@@ -894,6 +1061,7 @@ set -e
if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then
echo " - Generating SSH key."
SSH_KEY_DETAIL="generated new key"
test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal
test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub
ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify
@@ -906,20 +1074,18 @@ fi
chown -R 9999:root /data/coolify
chmod -R 700 /data/coolify
log "SSH key check completed"
echo " Done."
log_section "Step 9/9: Installing Coolify"
echo "9/9 Installing Coolify ($LATEST_VERSION)..."
echo -e " - It could take a while based on your server's performance, network speed, stars, etc."
echo -e " - Please wait."
getAJoke
step_done "$SSH_KEY_DETAIL"
step_start "Pulling images" "coolify · postgres · redis · helper"
# fd 3 is closed so the detached container restart in upgrade.sh does not hold the terminal open
if [[ $- == *x* ]]; then
bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&-
else
bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&-
fi
echo " - Coolify installed successfully."
step_done
step_start "Starting Coolify" "v${LATEST_VERSION}"
echo " - Waiting for Coolify to be ready..."
# Wait for upgrade.sh background process to complete
@@ -999,14 +1165,11 @@ if [ "$HEALTH" != "healthy" ]; then
echo " - Please check: docker logs coolify"
exit 1
fi
echo -e "\033[0;35m
____ _ _ _ _ _
/ ___|___ _ __ __ _ _ __ __ _| |_ _ _| | __ _| |_(_) ___ _ __ ___| |
| | / _ \| '_ \ / _\` | '__/ _\` | __| | | | |/ _\` | __| |/ _ \| '_ \/ __| |
| |__| (_) | | | | (_| | | | (_| | |_| |_| | | (_| | |_| | (_) | | | \__ \_|
\____\___/|_| |_|\__, |_| \__,_|\__|\__,_|_|\__,_|\__|_|\___/|_| |_|___(_)
|___/
\033[0m"
step_done
if [ "$UI_TTY" = true ]; then
printf '%s\n' "$(ui_bar 100 "done")" >&3
fi
# Fetch public IPs in parallel for faster completion
IPV4_TMP=$(mktemp)
@@ -1021,29 +1184,29 @@ IPV4_PUBLIC_IP=$(cat "$IPV4_TMP" 2>/dev/null || true)
IPV6_PUBLIC_IP=$(cat "$IPV6_TMP" 2>/dev/null || true)
rm -f "$IPV4_TMP" "$IPV6_TMP"
echo -e "\nYour instance is ready to use!\n"
if [ -n "$IPV4_PUBLIC_IP" ]; then
echo -e "You can access Coolify through your Public IPV4: http://$IPV4_PUBLIC_IP:8000"
fi
if [ -n "$IPV6_PUBLIC_IP" ]; then
echo -e "You can access Coolify through your Public IPv6: http://[$IPV6_PUBLIC_IP]:8000"
fi
set +e
DEFAULT_PRIVATE_IP=$(ip route get 1 | sed -n 's/^.*src \([0-9.]*\) .*$/\1/p')
PRIVATE_IPS=$(hostname -I 2>/dev/null || ip -o addr show scope global | awk '{print $4}' | cut -d/ -f1)
set -e
if [ -n "$PRIVATE_IPS" ]; then
echo -e "\nIf your Public IP is not accessible, you can use the following Private IPs:\n"
for IP in $PRIVATE_IPS; do
if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then
echo -e "http://$IP:8000"
fi
done
printf '\n %s%sCoolify is ready!%s %sInstalled in %s%s\n\n' "$C_BOLD" "$C_PURPLE" "$C_RESET" "$C_DIM" "$(ui_duration $(($(ui_now) - UI_INSTALL_STARTED)))" "$C_RESET" >&3
if [ -n "$IPV4_PUBLIC_IP" ]; then
printf ' Public IPv4 %shttp://%s:8000%s\n' "$C_BOLD" "$IPV4_PUBLIC_IP" "$C_RESET" >&3
fi
if [ -n "$IPV6_PUBLIC_IP" ]; then
printf ' Public IPv6 %shttp://[%s]:8000%s\n' "$C_BOLD" "$IPV6_PUBLIC_IP" "$C_RESET" >&3
fi
echo -e "\nWARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).\n"
PRIVATE_IP_LABEL="Private IP "
for IP in $PRIVATE_IPS; do
if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then
printf ' %s %shttp://%s:8000%s\n' "$PRIVATE_IP_LABEL" "$C_DIM" "$IP" "$C_RESET" >&3
PRIVATE_IP_LABEL=" "
fi
done
printf '\n %s! Back up %s/data/coolify/source/.env%s%s to a safe place outside this server (e.g. a password manager).%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3
printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
log_section "Installation Complete"
log "Coolify installation completed successfully"
+4 -4
View File
@@ -1,9 +1,9 @@
// Alpine data provider for the collapsible resource settings sidebar
// (x-data="settingsSidebarAccordion({ activeGroup, storageKey })").
//
// Only the group that contains the current page is open by default; every group
// can be collapsed/expanded and the choice is remembered per resource type. The
// active group is always forced open on load so the current page stays reachable.
// Every group is open by default; each group can be collapsed/expanded and the
// choice is remembered per resource type. The active group is always forced open
// on load so the current page stays reachable.
export function initializeSettingsSidebarAccordionComponent() {
window.Alpine.data('settingsSidebarAccordion', (config = {}) => ({
activeGroup: config.activeGroup || '',
@@ -43,7 +43,7 @@ export function initializeSettingsSidebarAccordionComponent() {
if (Object.prototype.hasOwnProperty.call(this.groups, group)) {
return this.groups[group];
}
return false;
return true;
},
toggle(group) {
this.groups = { ...this.groups, [group]: !this.isOpen(group) };
@@ -179,7 +179,7 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
// Group that holds the current page — always kept open, even if collapsed before.
$activeGroup = (string) $groupedMenuItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
// In-page sections (cards) shown as sub-items under the active page
@@ -46,7 +46,7 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
// Group that holds the current page — always kept open, even if collapsed before.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
$pageSections = $database->type() === 'standalone-postgresql'
@@ -187,8 +187,8 @@
->values();
$groupedServerMenuItems = $serverMenuItems->groupBy('group');
// Group that holds the current page (item or nested child) — the only one
// expanded by default.
// Group that holds the current page (item or nested child) — always kept
// open, even if collapsed before.
$activeGroup = (string) $groupedServerMenuItems->search(fn ($items) => $items->contains(
fn ($item) => ($item['active'] ?? false)
|| collect($item['children'] ?? [])->contains(fn ($child) => $child['active'] ?? false)
@@ -47,7 +47,7 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
// Group that holds the current page — always kept open, even if collapsed before.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
@endphp
@@ -53,6 +53,17 @@
@if ($labelsAreWritable)
<x-callout type="warning" title="Domains managed via labels" class="mb-4">
Container label readonly mode is disabled. Domains must be set in the Labels section on the General page.
@unless ($isCompose)
<a class="mt-2 block font-medium underline"
href="{{ route('project.application.configuration', [
'project_uuid' => data_get($application, 'environment.project.uuid'),
'environment_uuid' => data_get($application, 'environment.uuid'),
'application_uuid' => $application->uuid,
]) }}#container-labels-section"
{{ wireNavigate() }}>
Go to Container labels
</a>
@endunless
</x-callout>
@endif
@@ -47,7 +47,7 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
// Group that holds the current page — always kept open, even if collapsed before.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
$storageSections = $applications
@@ -81,6 +81,99 @@
</div>
</x-application.settings-section>
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<x-application.settings-section id="server-proxy-certificates-section" title="TLS certificates"
x-init="$wire.loadTraefikCertificates()"
helper="Review certificates stored in Traefik's acme.json file. Deleting an entry removes it from local ACME storage. It does not revoke the certificate at the certificate authority.">
<x-slot:actions>
<x-forms.button type="button" wire:click="loadTraefikCertificates"
wire:loading.attr="disabled" wire:target="loadTraefikCertificates">
Refresh
</x-forms.button>
</x-slot:actions>
<div wire:loading.flex wire:target="loadTraefikCertificates"
class="min-h-24 items-center justify-center">
<x-loading text="Loading TLS certificates…" />
</div>
<div wire:loading.remove wire:target="loadTraefikCertificates">
@if ($traefikCertificatesLoaded && count($traefikCertificates) === 0)
<x-empty size="sm" title="No TLS certificates found"
description="Traefik's ACME storage does not contain certificate entries."
icon-name="security" />
@elseif (count($traefikCertificates) > 0)
<div class="overflow-hidden rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="overflow-x-auto">
<table class="w-full min-w-3xl">
<thead>
<tr>
<th>Domain</th>
<th>Resolver</th>
<th>Alternative names</th>
<th>Expires</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@foreach ($traefikCertificates as $certificate)
<tr wire:key="traefik-certificate-{{ $certificate['id'] }}">
<td class="font-medium text-neutral-950 dark:text-fg">
{{ $certificate['main_domain'] }}
</td>
<td>
<span class="font-mono text-xs">{{ $certificate['resolver'] }}</span>
@if ($certificate['store'])
<span class="text-xs text-neutral-500 dark:text-fg-dim">
({{ $certificate['store'] }})
</span>
@endif
</td>
<td>
@if (count($certificate['sans']) > 0)
<div class="flex max-w-md flex-wrap gap-1">
@foreach ($certificate['sans'] as $domain)
<span
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-700 dark:bg-white/[0.06] dark:text-fg-dim">
{{ $domain }}
</span>
@endforeach
</div>
@else
<span class="text-neutral-500 dark:text-fg-dim">None</span>
@endif
</td>
<td>
{{ $certificate['expires_at'] ?? 'Unknown' }}
</td>
<td class="text-right">
@can('update', $server)
<x-modal-confirmation title="Delete TLS Certificate?"
buttonTitle="Delete"
submitAction="deleteTraefikCertificate({{ $certificate['id'] }})"
:actions="[
'Delete the certificate for '.$certificate['main_domain'].' from acme.json.',
'Restart Traefik before the running proxy stops using the certificate.',
]"
confirmationText="{{ $certificate['main_domain'] }}"
confirmationLabel="Confirm by entering the domain"
shortConfirmationLabel="Domain"
step2ButtonText="Delete Certificate"
isErrorButton :confirmWithPassword="false"
:confirmWithText="true" />
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
</x-application.settings-section>
@endif
@php
$proxyTitle =
$server->proxyType() === ProxyTypes::TRAEFIK->value
+4 -4
View File
@@ -14,13 +14,13 @@ Route::middleware(['web', 'auth', 'throttle:60,1'])->group(function () {
});
Route::post('/source/github/events', [Github::class, 'normal']);
Route::post('/source/github/events/manual', [Github::class, 'manual'])->middleware('throttle:60,1');
Route::post('/source/github/events/manual', [Github::class, 'manual']);
Route::post('/source/gitlab/events', [Gitlab::class, 'normal']);
Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual'])->middleware('throttle:60,1');
Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']);
Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual'])->middleware('throttle:60,1');
Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']);
Route::post('/source/gitea/events/manual', [Gitea::class, 'manual'])->middleware('throttle:60,1');
Route::post('/source/gitea/events/manual', [Gitea::class, 'manual']);
Route::post('/payments/stripe/events', [Stripe::class, 'events']);
+271 -108
View File
@@ -26,16 +26,222 @@ CURRENT_USER=$USER
if [ $EUID != 0 ]; then
echo "Please run this script as root or with sudo"
exit
exit 1
fi
echo ""
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/images
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
chown -R 9999:root /data/coolify
chmod -R 700 /data/coolify
INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
# Terminal UI: the terminal only shows the step list, everything else goes to the log file
TOTAL_STEPS=9
UI_STEP=0
UI_STEP_TITLE=""
UI_STEP_DETAIL=""
UI_STEP_STARTED=0
UI_SPINNER_PID=""
UI_WARNINGS=()
if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ]; then
UI_TTY=true
else
UI_TTY=false
fi
if [ "$UI_TTY" = true ] && [ -z "${NO_COLOR:-}" ]; then
C_RESET=$'\033[0m'
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
C_PURPLE=$'\033[38;5;135m'
C_GREEN=$'\033[32m'
C_RED=$'\033[31m'
C_YELLOW=$'\033[33m'
else
C_RESET="" C_BOLD="" C_DIM="" C_PURPLE="" C_GREEN="" C_RED="" C_YELLOW=""
fi
UI_WIDTH=$(tput cols 2>/dev/null || true)
if ! [[ $UI_WIDTH =~ ^[0-9]+$ ]]; then
UI_WIDTH=80
fi
UI_WIDTH=$((UI_WIDTH > 100 ? 100 : UI_WIDTH < 60 ? 60 : UI_WIDTH))
# Current time in milliseconds
ui_now() {
if [ -n "${EPOCHREALTIME:-}" ]; then
local now=${EPOCHREALTIME/[.,]/}
echo $((now / 1000))
else
echo $(($(date +%s) * 1000))
fi
}
ui_duration() {
local ms=$1
if [ "$ms" -lt 60000 ]; then
printf '%d.%ds' $((ms / 1000)) $((ms % 1000 / 100))
else
printf '%dm %ds' $((ms / 60000)) $((ms % 60000 / 1000))
fi
}
ui_repeat() {
local out="" i
for ((i = 0; i < $2; i++)); do
out+="$1"
done
printf '%s' "$out"
}
# Prints the current step line without the trailing status symbol
ui_render_step() {
local right="$1"
local title="$UI_STEP_TITLE"
local detail="$UI_STEP_DETAIL"
local plain_detail="${detail//·/.}"
local left=$((7 + ${#title}))
local right_width=$((${#right} + 2))
if [ -n "$detail" ] && [ $((left + 2 + ${#plain_detail} + right_width + 2)) -le "$UI_WIDTH" ]; then
left=$((left + 2 + ${#plain_detail}))
else
detail=""
fi
local pad=$((UI_WIDTH - left - right_width))
if [ "$pad" -lt 2 ]; then
pad=2
fi
printf ' %s%-5s%s%s%s%s' "$C_DIM" "${UI_STEP}/${TOTAL_STEPS}" "$C_RESET" "$C_BOLD" "$title" "$C_RESET"
if [ -n "$detail" ]; then
printf ' %s%s%s' "$C_DIM" "$detail" "$C_RESET"
fi
printf '%*s%s%s%s ' "$pad" "" "$C_DIM" "$right" "$C_RESET"
}
ui_bar() {
local percent=$1 label=$2
local cells=$((UI_WIDTH - 30 > 40 ? 40 : UI_WIDTH - 30))
local filled=$((percent * cells / 100))
printf ' %s%s%s%s%s %s%3d%%%s %s%s%s' \
"$C_PURPLE" "$(ui_repeat ■ "$filled")" "$C_DIM" "$(ui_repeat ■ $((cells - filled)))" "$C_RESET" \
"$C_BOLD" "$percent" "$C_RESET" "$C_DIM" "$label" "$C_RESET"
}
# Runs in the background and animates the current step line and the progress bar below it
ui_spinner() {
set +x
local frames=(◜ ◠ ◝ ◞ ◡ ◟) i=0 stop=false
local line bar
trap 'stop=true' TERM
line=$(ui_render_step "")
bar=$(ui_bar $(((UI_STEP - 1) * 100 / TOTAL_STEPS)) "${UI_STEP_TITLE,,}…")
while [ "$stop" = false ]; do
printf '\r\033[2K%s%s%s%s\n\033[2K%s\033[1A\r' "$line" "$C_PURPLE" "${frames[i]}" "$C_RESET" "$bar" >&3
i=$(((i + 1) % ${#frames[@]}))
sleep 0.12
done
}
ui_stop_spinner() {
if [ -n "$UI_SPINNER_PID" ]; then
kill "$UI_SPINNER_PID" 2>/dev/null || true
wait "$UI_SPINNER_PID" 2>/dev/null || true
UI_SPINNER_PID=""
fi
}
# Shows a warning below the current step (or right away when no step is running)
warn() {
echo "WARNING: $*"
UI_WARNINGS+=("$*")
}
ui_flush_warnings() {
local warning
for warning in "${UI_WARNINGS[@]}"; do
printf ' %s! %s%s\n' "$C_YELLOW" "$warning" "$C_RESET" >&3
done
UI_WARNINGS=()
}
step_start() {
UI_STEP=$((UI_STEP + 1))
UI_STEP_TITLE="$1"
UI_STEP_DETAIL="${2:-}"
UI_STEP_STARTED=$(ui_now)
log_section "Step ${UI_STEP}/${TOTAL_STEPS}: $1"
if [ "$UI_TTY" = true ]; then
ui_spinner &
UI_SPINNER_PID=$!
fi
}
# Usage: step_done [detail] - optionally replaces the detail text shown next to the step title
step_done() {
if [ -n "${1:-}" ]; then
UI_STEP_DETAIL="$1"
fi
ui_stop_spinner
local elapsed
elapsed=$(ui_duration $(($(ui_now) - UI_STEP_STARTED)))
log "Step ${UI_STEP}/${TOTAL_STEPS} completed in ${elapsed}"
if [ "$UI_TTY" = true ]; then
printf '\r\033[2K' >&3
fi
printf '%s%s✓%s\n' "$(ui_render_step "$elapsed")" "$C_GREEN" "$C_RESET" >&3
if [ "$UI_TTY" = true ]; then
printf '\033[2K' >&3
fi
ui_flush_warnings
UI_STEP_TITLE=""
}
ui_on_exit() {
local code=$?
ui_stop_spinner
if [ "$code" -ne 0 ]; then
if [ "$UI_TTY" = true ]; then
printf '\r\033[2K' >&3
fi
if [ -n "$UI_STEP_TITLE" ]; then
printf '%s%s✗%s\n' "$(ui_render_step "$(ui_duration $(($(ui_now) - UI_STEP_STARTED)))")" "$C_RED" "$C_RESET" >&3
fi
if [ "$UI_TTY" = true ]; then
printf '\033[2K' >&3
fi
ui_flush_warnings
printf '\n %s%sInstallation failed.%s Last lines of the log:\n\n' "$C_BOLD" "$C_RED" "$C_RESET" >&3
tail -n 15 "$INSTALLATION_LOG_WITH_DATE" 2>/dev/null | sed 's/^/ /' >&3
printf '\n %sFull log: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
fi
if [ "$UI_TTY" = true ]; then
printf '\033[?25h' >&3
fi
}
# fd 3 is the terminal, stdout and stderr go to the log file
exec 3>&1
exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1
trap ui_on_exit EXIT
if [ "$UI_TTY" = true ]; then
printf '\033[?25l' >&3
fi
printf '\n %sWelcome to Coolify Installer!%s\n' "$C_BOLD" "$C_RESET" >&3
printf ' %sThis script will install everything for you. Sit back and relax.%s\n' "$C_DIM" "$C_RESET" >&3
printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
UI_INSTALL_STARTED=$(ui_now)
echo "=========================================="
echo " Coolify Installation - ${DATE}"
echo "=========================================="
echo ""
echo "Welcome to Coolify Installer!"
echo "This script will install everything for you. Sit back and relax."
echo "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh"
# Predefined root user
@@ -157,9 +363,7 @@ if [ -f /etc/docker/daemon.json ]; then
if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ]; then
echo "Force override enabled - network pool will be updated with $DOCKER_ADDRESS_POOL_BASE/$DOCKER_ADDRESS_POOL_SIZE."
else
echo "Custom pool provided but force override not enabled - using existing configuration."
echo "To force override, set DOCKER_POOL_FORCE_OVERRIDE=true"
echo "This won't change the existing docker networks, only the pool configuration for the newly created networks."
warn "Custom Docker network pool ignored, using the existing $EXISTING_POOL_BASE/$EXISTING_POOL_SIZE. Set DOCKER_POOL_FORCE_OVERRIDE=true to override it (existing networks are not changed)."
DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE"
DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE"
DOCKER_POOL_BASE_PROVIDED=false
@@ -172,7 +376,7 @@ fi
# Validate Docker address pool configuration
if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
echo "Warning: Invalid network pool base format: $DOCKER_ADDRESS_POOL_BASE"
warn "Invalid Docker network pool base: $DOCKER_ADDRESS_POOL_BASE"
if [ "$EXISTING_POOL_CONFIGURED" = true ]; then
echo "Using existing configuration: $EXISTING_POOL_BASE"
DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE"
@@ -183,7 +387,7 @@ if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]];
fi
if ! [[ $DOCKER_ADDRESS_POOL_SIZE =~ ^[0-9]+$ ]] || [ "$DOCKER_ADDRESS_POOL_SIZE" -lt 16 ] || [ "$DOCKER_ADDRESS_POOL_SIZE" -gt 28 ]; then
echo "Warning: Invalid network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)"
warn "Invalid Docker network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)"
if [ "$EXISTING_POOL_CONFIGURED" = true ]; then
echo "Using existing configuration: $EXISTING_POOL_SIZE"
DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE"
@@ -201,53 +405,20 @@ WARNING_SPACE=false
if [ "$TOTAL_SPACE" -lt "$REQUIRED_TOTAL_SPACE" ]; then
WARNING_SPACE=true
cat <<EOF
WARNING: Insufficient total disk space!
Total disk space: ${TOTAL_SPACE}GB
Required disk space: ${REQUIRED_TOTAL_SPACE}GB
==================
EOF
warn "Insufficient total disk space: ${TOTAL_SPACE}GB (required: ${REQUIRED_TOTAL_SPACE}GB)"
fi
if [ "$AVAILABLE_SPACE" -lt "$REQUIRED_AVAILABLE_SPACE" ]; then
cat <<EOF
WARNING: Insufficient available disk space!
Available disk space: ${AVAILABLE_SPACE}GB
Required available space: ${REQUIRED_AVAILABLE_SPACE}GB
==================
EOF
warn "Insufficient available disk space: ${AVAILABLE_SPACE}GB (required: ${REQUIRED_AVAILABLE_SPACE}GB)"
WARNING_SPACE=true
fi
if [ "$WARNING_SPACE" = true ]; then
ui_flush_warnings
echo "Sleeping for 5 seconds."
sleep 5
fi
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/images
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
chown -R 9999:root /data/coolify
chmod -R 700 /data/coolify
INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
exec > >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1
getAJoke() {
JOKES=$(curl -s --max-time 2 "https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&type=single" || true)
if [ "$JOKES" != "" ]; then
echo -e " - Until then, here's a joke for you:\n"
echo -e "$JOKES\n"
fi
}
# Helper function to log with timestamp
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
@@ -330,7 +501,7 @@ case "$OS_TYPE" in
arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | amzn | alpine | postmarketos | tencentos) ;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now."
exit
exit 1
;;
esac
@@ -349,10 +520,9 @@ echo "| Helper | $LATEST_HELPER_VERSION"
echo "| Docker Pool | $DOCKER_ADDRESS_POOL_BASE (size $DOCKER_ADDRESS_POOL_SIZE)"
echo "| Registry URL | $REGISTRY_URL"
echo "---------------------------------------------"
echo ""
log_section "Step 1/9: Installing required packages"
echo "1/9 Installing required packages (curl, wget, git, jq, openssl)..."
ui_flush_warnings
step_start "Installing required packages" "curl wget git jq openssl"
# Track if apt-get update was run to avoid redundant calls later
APT_UPDATED=false
@@ -360,6 +530,7 @@ APT_UPDATED=false
if all_packages_installed; then
log "All required packages already installed, skipping installation"
echo " - All required packages already installed."
UI_STEP_DETAIL="already installed"
else
case "$OS_TYPE" in
arch)
@@ -394,15 +565,14 @@ else
;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now."
exit
exit 1
;;
esac
log "Required packages installed successfully"
fi
echo " Done."
step_done
log_section "Step 2/9: Checking OpenSSH server configuration"
echo "2/9 Checking OpenSSH server configuration..."
step_start "Checking OpenSSH server configuration"
# Detect OpenSSH server
SSH_DETECTED=false
@@ -477,8 +647,7 @@ SSH_PERMIT_ROOT_LOGIN=$(sshd -T | grep -i "permitrootlogin" | awk '{print $2}')
if [ "$SSH_PERMIT_ROOT_LOGIN" = "yes" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "without-password" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "prohibit-password" ]; then
echo " - SSH PermitRootLogin is enabled."
else
echo " - SSH PermitRootLogin is disabled."
echo " If you have problems with SSH, please read this: https://coolify.io/docs/knowledge-base/server/openssh"
warn "SSH PermitRootLogin is disabled. If you have problems with SSH, read https://coolify.io/docs/knowledge-base/server/openssh"
fi
# Detect if docker is installed via snap
@@ -543,11 +712,11 @@ install_docker_from_rhel_repo() {
systemctl --now enable docker
}
log_section "Step 3/9: Checking Docker installation"
echo "3/9 Checking Docker installation..."
step_done
if ! [ -x "$(command -v docker)" ]; then
step_start "Installing Docker Engine"
echo " - Docker is not installed. Installing Docker. It may take a while."
getAJoke
case "$OS_TYPE" in
"alpine" | "postmarketos")
apk add docker docker-cli-compose >/dev/null 2>&1
@@ -617,6 +786,7 @@ if ! [ -x "$(command -v docker)" ]; then
esac
echo " - Docker installed successfully."
else
step_start "Checking Docker Engine"
echo " - Docker is installed."
fi
@@ -624,7 +794,7 @@ fi
MIN_DOCKER_VERSION=24
INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
echo " - WARNING: Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
warn "Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
@@ -632,9 +802,10 @@ elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
else
echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
fi
DOCKER_SERVER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null || true)
step_done "${DOCKER_SERVER_VERSION:+v$DOCKER_SERVER_VERSION}"
log_section "Step 4/9: Checking Docker configuration"
echo "4/9 Checking Docker configuration..."
step_start "Configuring Docker daemon" "log rotation · address pools"
echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}"
echo " - To override existing configuration: DOCKER_POOL_FORCE_OVERRIDE=true"
@@ -763,8 +934,9 @@ else
fi
fi
log_section "Step 5/9: Downloading required files from CDN"
echo "5/9 Downloading required files from CDN..."
step_done
step_start "Downloading required files from CDN"
log "Downloading configuration files in parallel..."
# Download files in parallel for faster installation
@@ -794,10 +966,9 @@ fi
chmod +x /data/coolify/source/upgrade.sh /data/coolify/source/upgrade-postgres.sh
log "All configuration files downloaded successfully"
echo " Done."
step_done
log_section "Step 6/9: Setting up environment variable file"
echo "6/9 Setting up environment variable file..."
step_start "Setting up environment variables"
if [ -f "$ENV_FILE" ]; then
# If .env exists, create backup
@@ -813,10 +984,6 @@ else
cp "/data/coolify/source/.env.production" "$ENV_FILE"
fi
log "Environment file setup completed"
echo " Done."
log_section "Step 7/9: Checking and updating environment variables"
echo "7/9 Checking and updating environment variables..."
update_env_var() {
local key="$1"
@@ -878,10 +1045,10 @@ else
fi
fi
log "Environment variables check completed"
echo " Done."
step_done
log_section "Step 8/9: Checking SSH key for localhost access"
echo "8/9 Checking SSH key for localhost access..."
step_start "Checking SSH key for localhost access"
SSH_KEY_DETAIL="existing key"
if [ ! -f ~/.ssh/authorized_keys ]; then
mkdir -p ~/.ssh
chmod 700 ~/.ssh
@@ -895,6 +1062,7 @@ set -e
if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then
echo " - Generating SSH key."
SSH_KEY_DETAIL="generated new key"
test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal
test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub
ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify
@@ -907,20 +1075,18 @@ fi
chown -R 9999:root /data/coolify
chmod -R 700 /data/coolify
log "SSH key check completed"
echo " Done."
log_section "Step 9/9: Installing Coolify"
echo "9/9 Installing Coolify ($LATEST_VERSION)..."
echo -e " - It could take a while based on your server's performance, network speed, stars, etc."
echo -e " - Please wait."
getAJoke
step_done "$SSH_KEY_DETAIL"
step_start "Pulling images" "coolify · postgres · redis · helper"
# fd 3 is closed so the detached container restart in upgrade.sh does not hold the terminal open
if [[ $- == *x* ]]; then
bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&-
else
bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&-
fi
echo " - Coolify installed successfully."
step_done
step_start "Starting Coolify" "v${LATEST_VERSION}"
echo " - Waiting for Coolify to be ready..."
# Wait for upgrade.sh background process to complete
@@ -1000,14 +1166,11 @@ if [ "$HEALTH" != "healthy" ]; then
echo " - Please check: docker logs coolify"
exit 1
fi
echo -e "\033[0;35m
____ _ _ _ _ _
/ ___|___ _ __ __ _ _ __ __ _| |_ _ _| | __ _| |_(_) ___ _ __ ___| |
| | / _ \| '_ \ / _\` | '__/ _\` | __| | | | |/ _\` | __| |/ _ \| '_ \/ __| |
| |__| (_) | | | | (_| | | | (_| | |_| |_| | | (_| | |_| | (_) | | | \__ \_|
\____\___/|_| |_|\__, |_| \__,_|\__|\__,_|_|\__,_|\__|_|\___/|_| |_|___(_)
|___/
\033[0m"
step_done
if [ "$UI_TTY" = true ]; then
printf '%s\n' "$(ui_bar 100 "done")" >&3
fi
# Fetch public IPs in parallel for faster completion
IPV4_TMP=$(mktemp)
@@ -1022,29 +1185,29 @@ IPV4_PUBLIC_IP=$(cat "$IPV4_TMP" 2>/dev/null || true)
IPV6_PUBLIC_IP=$(cat "$IPV6_TMP" 2>/dev/null || true)
rm -f "$IPV4_TMP" "$IPV6_TMP"
echo -e "\nYour instance is ready to use!\n"
if [ -n "$IPV4_PUBLIC_IP" ]; then
echo -e "You can access Coolify through your Public IPV4: http://$IPV4_PUBLIC_IP:8000"
fi
if [ -n "$IPV6_PUBLIC_IP" ]; then
echo -e "You can access Coolify through your Public IPv6: http://[$IPV6_PUBLIC_IP]:8000"
fi
set +e
DEFAULT_PRIVATE_IP=$(ip route get 1 | sed -n 's/^.*src \([0-9.]*\) .*$/\1/p')
PRIVATE_IPS=$(hostname -I 2>/dev/null || ip -o addr show scope global | awk '{print $4}' | cut -d/ -f1)
set -e
if [ -n "$PRIVATE_IPS" ]; then
echo -e "\nIf your Public IP is not accessible, you can use the following Private IPs:\n"
for IP in $PRIVATE_IPS; do
if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then
echo -e "http://$IP:8000"
fi
done
printf '\n %s%sCoolify is ready!%s %sInstalled in %s%s\n\n' "$C_BOLD" "$C_PURPLE" "$C_RESET" "$C_DIM" "$(ui_duration $(($(ui_now) - UI_INSTALL_STARTED)))" "$C_RESET" >&3
if [ -n "$IPV4_PUBLIC_IP" ]; then
printf ' Public IPv4 %shttp://%s:8000%s\n' "$C_BOLD" "$IPV4_PUBLIC_IP" "$C_RESET" >&3
fi
if [ -n "$IPV6_PUBLIC_IP" ]; then
printf ' Public IPv6 %shttp://[%s]:8000%s\n' "$C_BOLD" "$IPV6_PUBLIC_IP" "$C_RESET" >&3
fi
echo -e "\nWARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).\n"
PRIVATE_IP_LABEL="Private IP "
for IP in $PRIVATE_IPS; do
if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then
printf ' %s %shttp://%s:8000%s\n' "$PRIVATE_IP_LABEL" "$C_DIM" "$IP" "$C_RESET" >&3
PRIVATE_IP_LABEL=" "
fi
done
printf '\n %s! Back up %s/data/coolify/source/.env%s%s to a safe place outside this server (e.g. a password manager).%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3
printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
log_section "Installation Complete"
log "Coolify installation completed successfully"
@@ -60,10 +60,26 @@ it('does not apply the REST API allowlist to MCP and MCP switch routes', functio
->and($disable->gatherMiddleware())->not->toContain(ApiAllowed::class);
});
it('throttles every manual webhook route', function (string $provider) {
it('throttles only failed authentication on manual webhook routes', function (string $provider) {
$route = Route::getRoutes()->match(Request::create("/webhooks/source/{$provider}/events/manual", 'POST'));
$request = Request::create("/webhooks/source/{$provider}/events/manual", 'POST', server: ['REMOTE_ADDR' => '192.0.2.44']);
$helper = new class
{
use MatchesManualWebhookApplications;
expect($route->gatherMiddleware())->toContain('throttle:60,1');
public function reply(array $payloads, Request $request, string $provider): int
{
return $this->manualWebhookResponse(collect($payloads), $request, $provider)->getStatusCode();
}
};
expect($route->gatherMiddleware())->not->toContain('throttle:60,1');
$helper->reply([['status' => 'success', 'message' => 'queued']], $request, $provider);
expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:192.0.2.44"))->toBe(0);
$helper->reply([['status' => 'failed', 'message' => 'Invalid signature.']], $request, $provider);
expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:192.0.2.44"))->toBe(1);
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
it('does not reveal how many applications share a manual webhook repository', function () {
@@ -73,7 +89,7 @@ it('does not reveal how many applications share a manual webhook repository', fu
public function reply(array $payloads): string
{
return $this->manualWebhookResponse(collect($payloads))->getContent();
return $this->manualWebhookResponse(collect($payloads), Request::create('/webhooks/source/github/events/manual', 'POST'), 'github')->getContent();
}
};
$failure = ['status' => 'failed', 'message' => 'Invalid signature.'];
@@ -278,6 +278,7 @@ function makeControlVarFilteringJob(Application $application, Server $server, ar
'mainServer' => $server,
'pull_request_id' => 0,
'commit' => 'HEAD',
'basedir' => '/artifacts/test-app',
'workdir' => '/artifacts/test-app',
'deployment_uuid' => 'deployment-uuid',
'dockerfile_location' => '/Dockerfile',
@@ -1078,6 +1079,7 @@ it('checks compose Dockerfiles with a portable command that skips missing files'
try {
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'basedir' => $workdir,
'workdir' => $workdir,
'env_args' => collect(['APP_ENV' => 'production']),
]);
+24 -3
View File
@@ -607,6 +607,27 @@ it('does not overwrite a completed preview dns result with stale checking state'
]);
});
it('links the labels warning to the container labels section', function () {
$this->application->settings()->update(['is_container_label_readonly_enabled' => false]);
$labelsUrl = route('project.application.configuration', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
]).'#container-labels-section';
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSee('Domains managed via labels')
->assertSee('href="'.$labelsUrl.'"', false)
->assertSee('Go to Container labels');
});
it('does not show the labels warning when Coolify manages labels', function () {
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertDontSee('Domains managed via labels')
->assertDontSee('#container-labels-section', false);
});
it('lists existing domains as individual rows', function () {
$this->application->update([
'fqdn' => 'https://example.com,https://www.example.com,https://another.example.com,https://www.another.example.com',
@@ -1021,17 +1042,16 @@ it('deletes the managed dns record when removing a domain by key with deleteMana
'provider_zone_id' => 'zone-1',
'name' => 'example.com',
]);
$record = ManagedDnsRecord::factory()->create([
$record = ManagedDnsRecord::factory()->owned()->create([
'team_id' => $this->team->id,
'integration_token_id' => $token->id,
'dns_provider_zone_id' => $zone->id,
'resource_type' => $this->application->getMorphClass(),
'resource_id' => $this->application->getKey(),
'provider_record_id' => 'record-1',
'type' => 'A',
'name' => 'app.example.com',
'content' => '203.0.113.10',
]);
$record->addReference($this->application);
Http::fake(['https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::sequence()
->push(['success' => true, 'result' => [
@@ -1039,6 +1059,7 @@ it('deletes the managed dns record when removing a domain by key with deleteMana
'type' => 'A',
'name' => 'app.example.com',
'content' => '203.0.113.10',
'comment' => $record->ownershipComment(),
]])
->push(['success' => true, 'result' => ['id' => 'record-1']])]);
@@ -0,0 +1,159 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\Process\Process;
uses(RefreshDatabase::class);
/**
* Runs deployment commands on this machine. A fake `docker` turns `docker exec <id> bash -c ...`
* into a local `bash -c ...`, so the real ARG injection commands run against a temporary repository.
*/
class LocallyExecutedComposeDeploymentJob extends ApplicationDeploymentJob
{
public string $binaries;
public array $logEntries = [];
public function __construct() {}
public function execute_remote_command(...$commands)
{
$savedOutputs = (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->getValue($this);
foreach ($commands as $command) {
$process = new Process(['/bin/bash', '-c', $command[0]], env: ['PATH' => $this->binaries.':'.getenv('PATH')]);
$process->run();
if (isset($command['save'])) {
$savedOutputs->put($command['save'], trim($process->getOutput()));
}
}
}
}
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->root = sys_get_temp_dir().'/coolify-compose-context-'.bin2hex(random_bytes(4));
$this->basedir = $this->root.'/artifacts/deployment';
mkdir($this->basedir.'/apps/web', 0755, true);
mkdir($this->root.'/bin');
file_put_contents($this->root.'/bin/docker', "#!/bin/sh\nif [ \"\$1\" = exec ]; then shift 2; exec \"\$@\"; fi\nexit 1\n");
chmod($this->root.'/bin/docker', 0755);
});
afterEach(function () {
(new Process(['rm', '-rf', $this->root]))->run();
});
function composeDockerfile(string $path): string
{
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, "FROM alpine\nRUN echo build\n");
return $path;
}
function runComposeArgInjection(object $test, mixed $build, string $workdir): LocallyExecutedComposeDeploymentJob
{
$application = Application::factory()->create(['build_pack' => 'dockercompose']);
$application->settings()->update(['inject_build_args_to_dockerfile' => true]);
$job = new LocallyExecutedComposeDeploymentJob;
$job->binaries = $test->root.'/bin';
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
$queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message) use ($job) {
$job->logEntries[] = $message;
});
foreach ([
'application' => $application->fresh(),
'application_deployment_queue' => $queue,
'deployment_uuid' => 'deployment-uuid',
'basedir' => $test->basedir,
'workdir' => $workdir,
'env_args' => collect(['APP_ENV' => 'production']),
'saved_outputs' => collect(),
'dockerSecretsSupported' => false,
'pull_request_id' => 0,
] as $property => $value) {
(new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
}
(new ReflectionMethod(ApplicationDeploymentJob::class, 'modify_dockerfiles_for_compose'))
->invoke($job, ['services' => ['api' => ['build' => $build]]]);
return $job;
}
test('compose deployments inject build args for Dockerfiles inside the repository', function (mixed $build, string $workdir, string $dockerfile) {
$dockerfile = composeDockerfile($this->basedir.'/'.$dockerfile);
// The build context folder always exists in a real repository.
@mkdir(rtrim($this->basedir.'/'.$workdir, '/').'/'.(is_array($build) ? $build['context'] : $build), 0755, true);
$job = runComposeArgInjection($this, $build, rtrim($this->basedir.'/'.$workdir, '/'));
expect(file_get_contents($dockerfile))->toContain("FROM alpine\nARG APP_ENV")
->and($job->logEntries)->toContain('Added 1 ARG declarations to Dockerfile for service api.');
})->with([
'context in the project directory' => ['.', '', 'Dockerfile'],
'monorepo context above the base directory' => [['context' => '../..'], 'apps/web', 'Dockerfile'],
'path with a space' => ['my app', '', 'my app/Dockerfile'],
'custom Dockerfile in a parent folder' => [['context' => 'services/api', 'dockerfile' => '../docker/api.Dockerfile'], '', 'services/docker/api.Dockerfile'],
]);
test('compose deployments skip build contexts they cannot inspect locally', function (mixed $build) {
$dockerfile = composeDockerfile($this->basedir.'/Dockerfile');
$job = runComposeArgInjection($this, $build, $this->basedir);
expect(file_get_contents($dockerfile))->not->toContain('ARG APP_ENV')
->and($job->logEntries)->toContain('The build context of service api is remote or uses variables, skipping ARG injection.');
})->with([
'git URL' => ['https://github.com/coollabsio/coolify.git#main:docker'],
'context variable' => ['${APP_DIR:-.}'],
'inline Dockerfile' => [['context' => '.', 'dockerfile_inline' => "FROM alpine\n"]],
]);
test('compose deployments never change Dockerfiles outside the repository', function (string $escape) {
$outside = composeDockerfile($this->root.'/outside/Dockerfile');
symlink($this->root.'/outside', $this->basedir.'/linked');
$build = match ($escape) {
'parent folder' => ['context' => '../../outside'],
'symlink' => 'linked',
'absolute path' => ['context' => $this->root.'/outside'],
};
$job = runComposeArgInjection($this, $build, $this->basedir);
expect(file_get_contents($outside))->not->toContain('ARG APP_ENV')
->and(collect($job->logEntries)->contains(fn (string $entry) => str_starts_with($entry, 'Dockerfile not found for service api')))->toBeTrue();
})->with(['parent folder', 'symlink', 'absolute path']);
test('compose build paths never run as shell commands', function (string $field, string $template) {
composeDockerfile($this->basedir.'/Dockerfile');
$marker = $this->root.'/pwned';
$value = str_replace('MARKER', $marker, $template);
$build = $field === 'context' ? ['context' => $value] : ['context' => '.', 'dockerfile' => $value];
runComposeArgInjection($this, $build, $this->basedir);
expect(file_exists($marker))->toBeFalse();
})->with([
'context semicolon' => ['context', '.; touch MARKER'],
'context command substitution' => ['context', '$(touch MARKER)'],
'context backticks' => ['context', '`touch MARKER`'],
'context newline' => ['context', ".\ntouch MARKER"],
'dockerfile semicolon' => ['dockerfile', 'Dockerfile; touch MARKER'],
'dockerfile quote' => ['dockerfile', "Dockerfile'; touch MARKER; '"],
'dockerfile newline' => ['dockerfile', "Dockerfile\ntouch MARKER"],
]);
test('compose deployments do not reject build paths before the build', function () {
// Compose resolves build contexts itself; only the ARG injection step inspects them, safely.
expect(method_exists(ApplicationDeploymentJob::class, 'validateComposeBuildPaths'))->toBeFalse();
});
@@ -0,0 +1,75 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\Process\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->root = sys_get_temp_dir().'/coolify-compose-load-'.bin2hex(random_bytes(4));
mkdir($this->root.'/src/apps/web', 0755, true);
mkdir($this->root.'/bin');
file_put_contents($this->root.'/bin/sudo', "#!/bin/sh\nexec \"\$@\"\n");
chmod($this->root.'/bin/sudo', 0755);
$this->compose = "services:\n api:\n build:\n context: ../..\n";
file_put_contents($this->root.'/src/apps/web/docker-compose.yml', $this->compose);
file_put_contents($this->root.'/src/README.md', 'monorepo');
foreach ([
['git', 'init', '-q', '-b', 'main'],
['git', 'add', '-A'],
['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'monorepo'],
['git', 'clone', '-q', '--bare', '.', $this->root.'/repo.git'],
] as $command) {
(new Process($command, $this->root.'/src'))->mustRun();
}
$this->commit = trim((new Process(['git', 'rev-parse', 'HEAD'], $this->root.'/src'))->mustRun()->getOutput());
});
afterEach(function () {
(new Process(['rm', '-rf', $this->root]))->run();
Mockery::close();
});
test('the Compose file loads through the server commands for root and non-root users', function (string $user, bool $pinCommit) {
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'git_repository' => $this->root.'/repo.git',
'git_branch' => 'main',
'git_commit_sha' => $pinCommit ? $this->commit : 'HEAD',
'base_directory' => '/apps/web',
'docker_compose_location' => '/docker-compose.yml',
]);
$application->settings->update(['is_git_submodules_enabled' => true]);
$uuid = 'compose-load-'.bin2hex(random_bytes(4));
$gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last();
$commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid, $gitVersion);
if ($user !== 'root') {
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn($user);
$server->shouldReceive('setAttribute')->andReturnSelf();
$commands = collect(parseCommandsByLineForSudo($commands, $server));
}
// Servers read the command list from stdin with `bash -se`, like instant_remote_process() over SSH.
$process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')]);
$process->setInput($commands->implode("\n")."\n");
$process->run();
(new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run();
expect($commands->implode("\n"))->not->toContain("cd '/artifacts/")
->and($process->getErrorOutput())->not->toContain('syntax error')
->and($process->getExitCode())->toBe(0)
->and($process->getOutput())->toBe($this->compose);
})->with([
'root' => ['root'],
'non-root' => ['ubuntu'],
])->with([
'branch head' => [false],
'pinned commit' => [true],
]);
@@ -0,0 +1,79 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
config(['constants.ssh.mux_enabled' => false]);
Log::spy();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$this->application = Application::factory()->create([
'build_pack' => 'dockercompose',
'git_repository' => 'https://github.com/coollabsio/private-repo',
'git_branch' => 'main',
'base_directory' => '/',
'docker_compose_location' => '/docker-compose.yml',
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$this->gitError = "Cloning into 'checkout'...\nfatal: unable to access 'https://x-access-token:ghs_SECRET123@github.com/coollabsio/private-repo.git/': The requested URL returned error: 403 <b>denied</b>";
});
function fakeComposeLoadServer(?string $failingStep, string $errorOutput): void
{
Process::fake(function ($process) use ($failingStep, $errorOutput) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
if ($failingStep !== null && str_contains($command, $failingStep)) {
return Process::result(errorOutput: $errorOutput, exitCode: 128);
}
return Process::result(output: str_contains($command, 'git --version') ? 'git version 2.43.0' : '');
});
}
it('shows and logs why the Compose file could not be read, without credentials', function () {
fakeComposeLoadServer('sparse-checkout', $this->gitError);
expect(fn () => $this->application->loadComposeFile())
->toThrow(function (RuntimeException $exception) {
expect($exception->getMessage())
->toContain('Failed to read the Docker Compose file from the repository.')
->toContain('The requested URL returned error: 403 &lt;b&gt;denied&lt;/b&gt;')
->toContain('https://***@github.com/')
->not->toContain('ghs_SECRET123')
->not->toContain('<b>');
});
Log::shouldHaveReceived('warning')->withArgs(fn (string $message, array $context) => $message === 'Failed to read the Docker Compose file from the repository.'
&& $context['application_uuid'] === $this->application->uuid
&& str_contains($context['error'], 'The requested URL returned error: 403')
&& ! str_contains(json_encode($context), 'ghs_SECRET123'));
});
it('shows and logs why the Git source could not be read, without credentials', function () {
fakeComposeLoadServer('ls-remote', $this->gitError);
expect(fn () => $this->application->loadComposeFile())
->toThrow(function (RuntimeException $exception) {
expect($exception->getMessage())
->toContain('Failed to read Git source. Please verify repository access and try again.')
->toContain('The requested URL returned error: 403')
->not->toContain('ghs_SECRET123');
});
Log::shouldHaveReceived('warning')->withArgs(fn (string $message, array $context) => $message === 'Failed to read Git source.'
&& ! str_contains(json_encode($context), 'ghs_SECRET123'));
});
@@ -0,0 +1,238 @@
<?php
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Actions\Database\RestartDatabase;
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StartDragonfly;
use App\Actions\Database\StartKeydb;
use App\Actions\Database\StartMariadb;
use App\Actions\Database\StartMongodb;
use App\Actions\Database\StartMysql;
use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StartRedis;
use App\Actions\Database\StopDatabase;
use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Events\DatabaseStatusChanged;
use App\Exceptions\DatabaseStartException;
use App\Jobs\DatabaseStartJob;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'docker']
);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'db',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'db',
'image' => 'postgres:17',
'status' => 'exited',
'environment_id' => $environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
/**
* A functional server that has no CA certificate and cannot generate one.
*/
function serverThatCannotProvideCaCertificate(int $teamId): Server
{
$server = new class extends Server
{
public function isFunctional()
{
return true;
}
public function ensureCaCertificate(): ?SslCertificate
{
return null;
}
};
return $server->forceFill(['id' => 999, 'uuid' => 'server-without-ca', 'team_id' => $teamId]);
}
function queuedDatabaseStartActivity(string $databaseUuid, int $teamId): Activity
{
return activity()
->withProperties([
'type' => ActivityTypes::INLINE->value,
'type_uuid' => $databaseUuid,
'status' => ProcessStatus::QUEUED->value,
'team_id' => $teamId,
'operation' => 'database-start',
])
->event(ActivityTypes::INLINE->value)
->log('[]');
}
dataset('ssl database start actions', [
'postgresql' => [StartPostgresql::class, StandalonePostgresql::class],
'mysql' => [StartMysql::class, StandaloneMysql::class],
'mariadb' => [StartMariadb::class, StandaloneMariadb::class],
'mongodb' => [StartMongodb::class, StandaloneMongodb::class],
'redis' => [StartRedis::class, StandaloneRedis::class],
'keydb' => [StartKeydb::class, StandaloneKeydb::class],
'dragonfly' => [StartDragonfly::class, StandaloneDragonfly::class],
]);
it('throws instead of silently returning when an SSL database has no CA certificate', function (string $action, string $model) {
Bus::fake();
$database = (new $model)->forceFill(['id' => 1, 'uuid' => 'ssl-db-uuid', 'enable_ssl' => true]);
$destination = new StandaloneDocker;
$destination->setRelation('server', serverThatCannotProvideCaCertificate($this->team->id));
$database->setRelation('destination', $destination);
expect(fn () => app($action)->handle($database))
->toThrow(DatabaseStartException::class, 'No CA certificate found');
// The old code queued a broken copy of the action via $this->dispatch('error', ...).
Bus::assertNothingDispatched();
})->with('ssl database start actions');
it('marks the activity as failed with the start error when the job fails', function () {
Event::fake([DatabaseStatusChanged::class]);
$activity = queuedDatabaseStartActivity($this->database->uuid, $this->team->id);
$job = new DatabaseStartJob(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null);
$job->failed(DatabaseStartException::missingCaCertificate());
$activity->refresh();
expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
->and(data_get($activity, 'properties.error'))->toContain('No CA certificate found')
->and(data_get($activity, 'properties.exitCode'))->toBe(1)
->and(RunRemoteProcess::decodeOutput($activity))->toContain('No CA certificate found');
});
it('keeps a generic message for unexpected job failures', function () {
Event::fake([DatabaseStatusChanged::class]);
$activity = queuedDatabaseStartActivity($this->database->uuid, $this->team->id);
$job = new DatabaseStartJob(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null);
$job->failed(new RuntimeException('SQLSTATE internal details'));
$activity->refresh();
expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
->and(data_get($activity, 'properties.error'))->toBe('Database start failed.')
->and(RunRemoteProcess::decodeOutput($activity))->not->toContain('SQLSTATE');
});
it('fails the queued activity when the start action throws inside the job', function () {
Event::fake([DatabaseStatusChanged::class]);
StartPostgresql::shouldRun()->andThrow(DatabaseStartException::missingCaCertificate());
$activity = queuedDatabaseStartActivity($this->database->uuid, $this->team->id);
expect(fn () => DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null))
->toThrow(DatabaseStartException::class);
$activity->refresh();
expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
->and(data_get($activity, 'properties.error'))->toContain('No CA certificate found');
});
it('fails the activity when the start action returns without running the start commands', function () {
Event::fake([DatabaseStatusChanged::class]);
StartPostgresql::shouldRun()->andReturnNull();
$activity = queuedDatabaseStartActivity($this->database->uuid, $this->team->id);
expect(fn () => DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null))
->toThrow(DatabaseStartException::class);
$activity->refresh();
expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value);
});
it('marks the activity as failed and rethrows when queueing the start job fails', function () {
Bus::shouldReceive('dispatch')->andThrow(new RuntimeException('Queue connection refused'));
expect(fn () => StartDatabase::run($this->database))
->toThrow(RuntimeException::class, 'Queue connection refused');
$activity = Activity::query()->where('properties->type_uuid', $this->database->uuid)->sole();
expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
->and(data_get($activity, 'properties.error'))->toBe('Database start could not be queued.')
->and(data_get($activity, 'properties.exitCode'))->toBe(1);
});
it('returns an immediate error without queueing when SSL is enabled and no CA certificate is available', function () {
Bus::fake();
$this->database->forceFill(['enable_ssl' => true])->save();
$destination = new StandaloneDocker;
$destination->setRelation('server', serverThatCannotProvideCaCertificate($this->team->id));
$this->database->setRelation('destination', $destination);
$result = StartDatabase::run($this->database);
expect($result)->toBeString()->toContain('No CA certificate found');
expect(Activity::query()->where('properties->type_uuid', $this->database->uuid)->exists())->toBeFalse();
Bus::assertNotDispatched(DatabaseStartJob::class);
});
it('does not stop the database on restart when the start prerequisites are missing', function () {
Bus::fake();
StopDatabase::shouldRun()->never();
$this->database->forceFill(['enable_ssl' => true])->save();
$destination = new StandaloneDocker;
$destination->setRelation('server', serverThatCannotProvideCaCertificate($this->team->id));
$this->database->setRelation('destination', $destination);
$result = RestartDatabase::run($this->database);
expect($result)->toBeString()->toContain('No CA certificate found');
});
it('queues the start when SSL is enabled and the server already has a CA certificate', function () {
Bus::fake();
$this->database->forceFill(['enable_ssl' => true])->save();
SslCertificate::create([
'ssl_certificate' => 'ca-cert',
'ssl_private_key' => 'ca-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYear(),
'is_ca_certificate' => true,
'server_id' => $this->server->id,
]);
$result = StartDatabase::run($this->database);
expect($result)->toBeInstanceOf(Activity::class);
Bus::assertDispatched(DatabaseStartJob::class);
});
@@ -12,6 +12,7 @@ use App\Models\ScheduledVolumeBackup;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Notifications\Internal\GeneralNotification;
@@ -179,9 +180,39 @@ it('targets a service subresource container by its Docker labels', function () {
expect($commands->implode("\n"))
->toContain("label=coolify.serviceId={$service->id}")
->toContain("label=coolify.service.subId={$application->id}")
->toContain('label=coolify.service.subType=application')
->toContain('docker rm -f $container_ids');
});
it('removes only the database container when an application of the same service has the same id', function () {
// Service applications and databases are separate tables, so they can share an id.
$service = Service::factory()->create([
'environment_id' => $this->application->environment_id,
'server_id' => $this->application->destination->server_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
]);
$application = ServiceApplication::create(['service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine']);
$database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => 'postgres:17-alpine']);
$database->forceFill(['id' => $application->id])->save();
$privateKey = PrivateKey::factory()->create(['team_id' => $service->server->team_id]);
$service->server->update(['private_key_id' => $privateKey->id]);
$service->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$commands = collect();
Process::fake(function ($process) use ($commands) {
$commands->push($process->command);
return Process::result(output: '');
});
app(DeleteService::class)->removeSubresourceContainer($database->fresh());
expect($commands->implode("\n"))
->toContain("label=coolify.service.subId={$application->id}")
->toContain('label=coolify.service.subType=database')
->not->toContain('label=coolify.service.subType=application');
});
it('rolls back local metadata deletion when deleting the resource fails', function () {
Process::fake(['*' => Process::result(output: '')]);
$applicationUuid = $this->application->uuid;
+39 -24
View File
@@ -1,5 +1,6 @@
<?php
use App\Enums\ManagedDnsDeletionResult;
use App\Events\DnsRecordConfigurationFinished;
use App\Exceptions\DnsRecordConflictException;
use App\Jobs\CheckDomainDnsJob;
@@ -109,10 +110,12 @@ test('a cloudflare record is created and tracked as managed by coolify', functio
$record = app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10');
expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10');
expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10')
->and($record->owned)->toBeTrue();
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->data()['name'] === 'app.example.com'
&& $request->data()['content'] === '203.0.113.10');
&& $request->data()['content'] === '203.0.113.10'
&& $request->data()['comment'] === $record->ownershipComment());
});
test('queued dns configuration creates the record and broadcasts completion', function () {
@@ -201,6 +204,7 @@ test('an existing matching remote record is tracked without creating a new one',
expect($record->provider_record_id)->toBe('record-1')
->and($record->content)->toBe('203.0.113.10')
->and($record->owned)->toBeFalse()
->and(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeTrue();
Http::assertNotSent(fn ($request) => $request->method() === 'POST');
});
@@ -260,28 +264,41 @@ test('an existing remote record with different content remains a conflict', func
});
test('a managed record changed outside coolify is not deleted', function () {
$record = ManagedDnsRecord::factory()->create([
$record = ManagedDnsRecord::factory()->owned()->create([
'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
]);
Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::response([
'success' => true,
'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.99'],
'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.99', 'comment' => $record->ownershipComment()],
])]);
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeFalse()->and($record->fresh())->not->toBeNull();
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::ChangedExternally)
->and($record->fresh())->not->toBeNull();
Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
});
test('a record coolify did not create is never deleted', function () {
$record = ManagedDnsRecord::factory()->create([
'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
]);
Http::fake();
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::NotOwned)
->and($record->fresh())->not->toBeNull();
Http::assertNothingSent();
});
test('an unchanged managed record is deleted from cloudflare and coolify', function () {
$record = ManagedDnsRecord::factory()->create([
$record = ManagedDnsRecord::factory()->owned()->create([
'provider_record_id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
]);
Http::fake(['https://api.cloudflare.com/client/v4/zones/*/dns_records/record-1' => Http::sequence()
->push(['success' => true, 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']])
->push(['success' => true, 'result' => ['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => $record->ownershipComment()]])
->push(['success' => true, 'result' => ['id' => 'record-1']])]);
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBeTrue()
expect(app(CloudflareDnsProvider::class)->deleteRecord($record))->toBe(ManagedDnsDeletionResult::Deleted)
->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
});
@@ -491,32 +508,30 @@ test('removing a domain deletes only the managed dns record for that resource',
$token = IntegrationToken::factory()->for($team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
$zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
$otherRecord = ManagedDnsRecord::factory()->create([
$otherRecord = ManagedDnsRecord::factory()->owned()->create([
'team_id' => $team->id,
'integration_token_id' => $token->id,
'dns_provider_zone_id' => $zone->id,
'resource_type' => $otherApplication->getMorphClass(),
'resource_id' => $otherApplication->getKey(),
'provider_record_id' => 'record-other',
'type' => 'A',
'name' => 'app.example.com',
'content' => '203.0.113.10',
]);
$ownRecord = ManagedDnsRecord::factory()->create([
$otherRecord->addReference($otherApplication);
$ownRecord = ManagedDnsRecord::factory()->owned()->create([
'team_id' => $team->id,
'integration_token_id' => $token->id,
'dns_provider_zone_id' => $zone->id,
'resource_type' => $application->getMorphClass(),
'resource_id' => $application->getKey(),
'provider_record_id' => 'record-own',
'type' => 'A',
'name' => 'app.example.com',
'content' => '203.0.113.10',
]);
$ownRecord->addReference($application);
Http::fake([
'https://api.cloudflare.com/client/v4/zones/*/dns_records/record-own' => Http::sequence()
->push(['success' => true, 'result' => ['id' => 'record-own', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10']])
->push(['success' => true, 'result' => ['id' => 'record-own', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => $ownRecord->ownershipComment()]])
->push(['success' => true, 'result' => ['id' => 'record-own']]),
'https://api.cloudflare.com/client/v4/zones/*/dns_records/record-other' => Http::response([
'success' => true,
@@ -553,11 +568,11 @@ test('replaceRecord updates the cloudflare record when the conflict still matche
$zone, 'record-1', 'app.example.com', '203.0.113.10', expectedCurrent: '198.51.100.50',
);
expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10');
Http::assertSent(fn ($request) => $request->method() === 'PUT'
expect($record->provider_record_id)->toBe('record-1')->and($record->content)->toBe('203.0.113.10')
->and($record->owned)->toBeFalse();
Http::assertSent(fn ($request) => $request->method() === 'PATCH'
&& str_ends_with($request->url(), '/dns_records/record-1')
&& $request->data()['name'] === 'app.example.com'
&& $request->data()['content'] === '203.0.113.10');
&& $request->data() === ['content' => '203.0.113.10']);
});
test('replaceRecord rejects a stale or tampered conflict without updating dns', function (string $recordId, string $current) {
@@ -576,7 +591,7 @@ test('replaceRecord rejects a stale or tampered conflict without updating dns',
$zone, $recordId, 'app.example.com', '203.0.113.10', expectedCurrent: $current,
))->toThrow(RuntimeException::class, 'The DNS conflict is no longer available. Check the record again.');
Http::assertNotSent(fn ($request) => $request->method() === 'PUT');
Http::assertNotSent(fn ($request) => in_array($request->method(), ['PUT', 'PATCH'], true));
})->with([
'wrong record id' => ['record-other', '198.51.100.50'],
'wrong current value' => ['record-1', '203.0.113.99'],
@@ -606,10 +621,10 @@ test('replacing a managed dns record uses the server ip and live cloudflare reco
->call('replaceManagedDnsRecord', 'app.example.com', $zone->id)
->assertDispatched('success', 'DNS record replaced for app.example.com.');
Http::assertSent(fn ($request) => $request->method() === 'PUT'
Http::assertSent(fn ($request) => $request->method() === 'PATCH'
&& str_ends_with($request->url(), '/dns_records/record-1')
&& $request->data()['content'] === '203.0.113.10');
expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->where('content', '203.0.113.10')->exists())->toBeTrue();
&& $request->data() === ['content' => '203.0.113.10']);
expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->where('content', '203.0.113.10')->where('owned', false)->exists())->toBeTrue();
});
test('replacing a managed dns record ignores a tampered conflict record id', function () {
@@ -637,7 +652,7 @@ test('replacing a managed dns record ignores a tampered conflict record id', fun
->assertDispatched('error', 'The DNS conflict is no longer available. Check the record again.')
->assertSet('dnsProviderConflicts', []);
Http::assertNotSent(fn ($request) => $request->method() === 'PUT');
Http::assertNotSent(fn ($request) => in_array($request->method(), ['PUT', 'PATCH'], true));
expect(ManagedDnsRecord::query()->where('name', 'app.example.com')->exists())->toBeFalse();
});
@@ -1,7 +1,9 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Actions\Server\StopLogDrain;
use App\Livewire\Server\LogDrains;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -43,3 +45,30 @@ it('keeps the enabled flag persisted when starting the log drain succeeds', func
expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeTruthy();
});
it('opens the log drain page without writing an audit event', function () {
$this->withoutDefer();
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->assertOk()
->assertSet('isLogDrainNewRelicEnabled', false);
expect(AuditEvent::query()->where('event', 'like', 'ui.server.log_drain.%')->count())->toBe(0);
});
it('audits enabling and disabling a log drain with its provider', function () {
$this->withoutDefer();
StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
StopLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->set('logDrainNewRelicLicenseKey', 'abc123')
->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com')
->call('toggleLogDrain', 'newrelic')
->call('toggleLogDrain', 'newrelic');
$events = AuditEvent::query()->whereIn('event', ['ui.server.log_drain.enabled', 'ui.server.log_drain.disabled'])->orderBy('id')->get();
expect($events->pluck('event')->all())->toBe(['ui.server.log_drain.enabled', 'ui.server.log_drain.disabled'])
->and($events->pluck('metadata.provider')->all())->toBe(['newrelic', 'newrelic']);
});
@@ -0,0 +1,508 @@
<?php
use App\Jobs\ReleaseManagedDnsRecordsJob;
use App\Livewire\Project\Application\Domains;
use App\Livewire\Project\Service\Domains as ServiceDomains;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\Dns\CloudflareDnsProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
config()->set('app.maintenance.store', 'array');
config()->set('app.id', 'test-instance');
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['id' => 0, 'is_dns_validation_enabled' => false],
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'ip' => '203.0.113.10']);
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$this->destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker'],
));
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'secret']);
$this->zone = DnsProviderZone::factory()->for($this->token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
});
/**
* Fakes the Cloudflare DNS record API with an in-memory record store.
*
* @param array<int, array<string, mixed>> $records
*/
function fakeCloudflareDns(array $records = []): ArrayObject
{
$state = new ArrayObject(['records' => collect($records)->keyBy('id')->all(), 'next' => 1]);
Http::fake(function (Request $request) use ($state) {
if (! preg_match('#^https://api\.cloudflare\.com/client/v4/zones/[^/]+/dns_records(?:/([^/?]+))?(?:\?(.*))?$#', $request->url(), $matches)) {
return Http::response(['success' => false], 404);
}
$id = ($matches[1] ?? '') !== '' ? $matches[1] : null;
$records = $state['records'];
if ($id === null && $request->method() === 'GET') {
parse_str($matches[2] ?? '', $query);
$result = collect($records)->filter(fn (array $record): bool => $record['name'] === ($query['name'] ?? null)
&& $record['type'] === ($query['type'] ?? null))->values()->all();
return Http::response(['success' => true, 'result' => $result]);
}
if ($id === null && $request->method() === 'POST') {
$id = 'record-new-'.$state['next'];
$state['next']++;
$records[$id] = array_merge(['proxied' => false, 'ttl' => 1, 'comment' => null], $request->data(), ['id' => $id]);
$state['records'] = $records;
return Http::response(['success' => true, 'result' => $records[$id]]);
}
if (! isset($records[$id])) {
return Http::response(['success' => false, 'errors' => [['code' => 81044, 'message' => 'Record does not exist.']]], 404);
}
if ($request->method() === 'PATCH') {
$records[$id] = array_merge($records[$id], $request->data());
$state['records'] = $records;
}
if ($request->method() === 'DELETE') {
unset($records[$id]);
$state['records'] = $records;
return Http::response(['success' => true, 'result' => ['id' => $id]]);
}
return Http::response(['success' => true, 'result' => $records[$id]]);
});
return $state;
}
function createDnsTestApplication(object $test, string $fqdn): Application
{
$application = Application::factory()->create([
'environment_id' => $test->environment->id,
'destination_id' => $test->destination->id,
'destination_type' => $test->destination->getMorphClass(),
'fqdn' => $fqdn,
'build_pack' => 'nixpacks',
]);
$application->settings()->update(['is_container_label_readonly_enabled' => true]);
return $application->fresh();
}
function sentDnsRequests(string $method): int
{
return Http::recorded(fn (Request $request) => $request->method() === $method
&& str_contains($request->url(), 'api.cloudflare.com'))->count();
}
test('a created record carries the ownership comment and is owned', function () {
fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
expect($record->owned)->toBeTrue()
->and($record->ownershipComment())->toBe("managed-by: coolify test-instance/{$record->uuid}")
->and($record->references()->where('resource_id', $application->id)->exists())->toBeTrue();
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
&& $request->data()['comment'] === $record->ownershipComment()
&& $request->data()['proxied'] === false);
});
test('an adopted hand-made record is never deleted when the domain is removed', function () {
$cloudflare = fakeCloudflareDns([[
'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
'proxied' => true, 'ttl' => 300, 'comment' => 'hand made',
]]);
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
expect($record->owned)->toBeFalse()->and($record->references()->count())->toBe(1);
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and(sentDnsRequests('POST'))->toBe(0)
->and(sentDnsRequests('PATCH'))->toBe(0)
->and($cloudflare['records'])->toHaveKey('record-1')
->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
});
test('replacing a hand-made record keeps proxied, ttl and comment and never deletes it later', function () {
$cloudflare = fakeCloudflareDns([[
'id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50',
'proxied' => true, 'ttl' => 300, 'comment' => 'hand made',
]]);
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->replaceRecord(
$this->zone, 'record-1', 'app.example.com', '203.0.113.10', $application, expectedCurrent: '198.51.100.50',
);
Http::assertSent(fn (Request $request) => $request->method() === 'PATCH'
&& str_ends_with($request->url(), '/dns_records/record-1')
&& $request->data() === ['content' => '203.0.113.10']);
expect($record->owned)->toBeFalse()
->and($cloudflare['records']['record-1'])->toMatchArray([
'content' => '203.0.113.10', 'proxied' => true, 'ttl' => 300, 'comment' => 'hand made',
]);
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and($cloudflare['records'])->toHaveKey('record-1');
});
test('an owned record is kept while another resource references it and deleted after the last reference', function () {
$cloudflare = fakeCloudflareDns();
$first = createDnsTestApplication($this, 'https://app.example.com');
$second = createDnsTestApplication($this, 'https://app.example.com/api');
$provider = app(CloudflareDnsProvider::class);
$record = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $first);
$adopted = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $second);
expect($adopted->id)->toBe($record->id)
->and($adopted->fresh()->owned)->toBeTrue()
->and($record->references()->count())->toBe(2)
->and(sentDnsRequests('POST'))->toBe(1);
Livewire::test(Domains::class, ['application' => $first])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and($record->fresh())->not->toBeNull()
->and($record->references()->pluck('resource_id')->all())->toBe([$second->id]);
Livewire::test(Domains::class, ['application' => $second])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(1)
->and($cloudflare['records'])->toBeEmpty()
->and($record->fresh())->toBeNull();
});
test('another application of the same team still using the hostname prevents deletion and takes over the reference', function () {
$cloudflare = fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
$otherApplication = createDnsTestApplication($this, 'http://other.example.org,https://APP.example.com:8443');
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and($cloudflare['records'])->toHaveCount(1)
->and($record->fresh())->not->toBeNull()
->and($record->references()->pluck('resource_id')->all())->toBe([$otherApplication->id]);
$otherApplication->delete();
expect(sentDnsRequests('DELETE'))->toBe(1)
->and($cloudflare['records'])->toBeEmpty()
->and($record->fresh())->toBeNull();
});
test('an application of another team using the hostname prevents deletion', function () {
$cloudflare = fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
Application::factory()->create([
'environment_id' => $otherEnvironment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://app.example.com',
'build_pack' => 'nixpacks',
]);
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and($cloudflare['records'])->toHaveCount(1)
->and(ManagedDnsRecord::query()->find($record->id))->toBeNull();
});
test('a hostname still used by a compose application or preview prevents deletion', function (string $usage) {
fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
$other = createDnsTestApplication($this, 'https://unrelated.example.org');
if ($usage === 'compose') {
$other->update(['docker_compose_domains' => json_encode(['web' => ['domain' => 'https://app.example.com']])]);
} else {
ApplicationPreview::query()->create([
'application_id' => $other->id, 'pull_request_id' => 7, 'pull_request_html_url' => 'https://github.com/x/y/pull/7',
'fqdn' => 'https://app.example.com',
]);
}
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)->and($record->fresh())->not->toBeNull();
})->with(['compose', 'preview']);
test('an owned record whose remote comment was changed or removed is not deleted', function (?string $comment) {
$cloudflare = fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
$records = $cloudflare['records'];
$records[$record->provider_record_id]['comment'] = $comment;
$cloudflare['records'] = $records;
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and($cloudflare['records'])->toHaveKey($record->provider_record_id)
->and($record->fresh())->toBeNull();
})->with([
'removed' => [null],
'changed' => ['production record'],
'other coolify record' => ['managed-by: coolify test-instance/someotherrecord'],
]);
test('removing a domain without deleting dns forgets the reference and keeps the remote record', function () {
$cloudflare = fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', [])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and($cloudflare['records'])->toHaveCount(1)
->and($record->fresh())->toBeNull();
});
test('removing one of two urls with the same hostname keeps the reference', function () {
$cloudflare = fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com,http://app.example.com:8080');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and($record->fresh())->not->toBeNull()
->and($record->references()->pluck('resource_id')->all())->toBe([$application->id]);
});
test('an existing round-robin record with the wanted content satisfies the request', function () {
fakeCloudflareDns([
['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50', 'comment' => null],
['id' => 'record-2', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => null],
]);
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
expect($record->provider_record_id)->toBe('record-2')->and($record->owned)->toBeFalse();
expect(sentDnsRequests('POST'))->toBe(0);
});
test('several round-robin records without the wanted content are not replaced', function () {
fakeCloudflareDns([
['id' => 'record-1', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.50'],
['id' => 'record-2', 'type' => 'A', 'name' => 'app.example.com', 'content' => '198.51.100.51'],
]);
$provider = app(CloudflareDnsProvider::class);
expect(fn () => $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10'))
->toThrow(RuntimeException::class, 'Several DNS records already exist for app.example.com');
expect(fn () => $provider->replaceRecord($this->zone, 'record-1', 'app.example.com', '203.0.113.10', expectedCurrent: '198.51.100.50'))
->toThrow(RuntimeException::class);
expect(sentDnsRequests('POST') + sentDnsRequests('PATCH') + sentDnsRequests('PUT'))->toBe(0);
});
test('deleting an application removes its owned dns records', function () {
$cloudflare = fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
$application->delete();
expect(sentDnsRequests('DELETE'))->toBe(1)
->and($cloudflare['records'])->toBeEmpty()
->and($record->fresh())->toBeNull();
});
test('deleting a service application or preview removes its owned dns records', function (string $kind) {
$cloudflare = fakeCloudflareDns();
if ($kind === 'service application') {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$resource = ServiceApplication::create([
'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine',
'fqdn' => 'https://app.example.com',
]);
} else {
$application = createDnsTestApplication($this, 'https://main.example.org');
$resource = ApplicationPreview::query()->create([
'application_id' => $application->id, 'pull_request_id' => 3, 'pull_request_html_url' => 'https://github.com/x/y/pull/3',
'fqdn' => 'https://app.example.com',
]);
}
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $resource);
$resource->delete();
expect(sentDnsRequests('DELETE'))->toBe(1)
->and($cloudflare['records'])->toBeEmpty()
->and($record->fresh())->toBeNull();
})->with(['service application', 'preview']);
test('deleting an application also releases the references of its previews', function () {
$cloudflare = fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://main.example.org');
$preview = ApplicationPreview::query()->create([
'application_id' => $application->id, 'pull_request_id' => 4, 'pull_request_html_url' => 'https://github.com/x/y/pull/4',
'fqdn' => 'https://pr-4.example.com',
]);
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'pr-4.example.com', '203.0.113.10', $preview);
$application->forceDelete();
expect($cloudflare['records'])->toBeEmpty()->and($record->fresh())->toBeNull();
});
test('resource deletion succeeds and keeps the record when cloudflare is unreachable', function () {
fakeCloudflareDns();
$application = createDnsTestApplication($this, 'https://app.example.com');
$record = app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $application);
Http::fake(fn () => throw new ConnectionException('Cloudflare is unreachable.'));
$application->forceDelete();
expect(Application::withTrashed()->find($application->id))->toBeNull()
->and($record->fresh())->not->toBeNull()
->and($record->references()->count())->toBe(1);
});
test('deleting a resource dispatches the release job only when it references dns records', function () {
Queue::fake();
fakeCloudflareDns();
$withRecord = createDnsTestApplication($this, 'https://app.example.com');
$withoutRecord = createDnsTestApplication($this, 'https://plain.example.com');
app(CloudflareDnsProvider::class)->createRecord($this->zone, 'app.example.com', '203.0.113.10', $withRecord);
$withoutRecord->delete();
Queue::assertNotPushed(ReleaseManagedDnsRecordsJob::class);
$withRecord->delete();
Queue::assertPushed(ReleaseManagedDnsRecordsJob::class, fn (ReleaseManagedDnsRecordsJob $job): bool => $job->resourceType === $withRecord->getMorphClass()
&& (string) $job->resourceId === (string) $withRecord->id);
});
test('service domain removal releases only the removed service application reference', function () {
$cloudflare = fakeCloudflareDns();
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n api:\n image: nginx:alpine\n",
]);
$web = ServiceApplication::create([
'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'web', 'image' => 'nginx:alpine',
'fqdn' => 'https://app.example.com',
]);
$api = ServiceApplication::create([
'uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'api', 'image' => 'nginx:alpine',
'fqdn' => 'https://app.example.com/api',
]);
$provider = app(CloudflareDnsProvider::class);
$record = $provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $web);
$provider->createRecord($this->zone, 'app.example.com', '203.0.113.10', $api);
Livewire::test(ServiceDomains::class, ['service' => $service->fresh(['applications', 'server'])])
->call('removeDomainByKey', hash('sha256', 'https://app.example.com/api|'.$api->id), '', ['deleteManagedDns'])
->assertDispatched('success');
expect(sentDnsRequests('DELETE'))->toBe(0)
->and($cloudflare['records'])->toHaveCount(1)
->and($record->references()->pluck('resource_id')->all())->toBe([$web->id]);
});
test('releasing a hostname never touches records of another team', function () {
$cloudflare = fakeCloudflareDns([[
'id' => 'record-foreign', 'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10', 'comment' => null,
]]);
$application = createDnsTestApplication($this, 'https://app.example.com');
$otherToken = IntegrationToken::factory()->for(Team::factory()->create())->create(['provider' => 'cloudflare', 'token' => 'other']);
$otherZone = DnsProviderZone::factory()->for($otherToken)->create(['provider_zone_id' => 'zone-2', 'name' => 'example.com']);
$foreignRecord = ManagedDnsRecord::factory()->owned()->create([
'dns_provider_zone_id' => $otherZone->id, 'provider_record_id' => 'record-foreign',
'type' => 'A', 'name' => 'app.example.com', 'content' => '203.0.113.10',
]);
$foreignRecord->addReference($application);
Livewire::test(Domains::class, ['application' => $application])
->call('removeDomain', 0, '', ['deleteManagedDns'])
->assertDispatched('success');
expect(Http::recorded())->toBeEmpty()
->and($cloudflare['records'])->toHaveKey('record-foreign')
->and($foreignRecord->fresh())->not->toBeNull()
->and($foreignRecord->references()->count())->toBe(1);
});
@@ -0,0 +1,36 @@
<?php
use App\Actions\Proxy\DeleteTraefikCertificate;
use App\Actions\Proxy\GetTraefikCertificates;
it('shows Traefik ACME certificates with protected delete controls', function () {
$view = file_get_contents(resource_path('views/livewire/server/proxy.blade.php'));
$component = file_get_contents(app_path('Livewire/Server/Proxy.php'));
expect($view)
->toContain('TLS certificates')
->toContain('wire:click="loadTraefikCertificates"')
->toContain('submitAction="deleteTraefikCertificate')
->toContain("@can('update', \$server)")
->and($component)
->toContain('public function loadTraefikCertificates(): void')
->toContain('public function deleteTraefikCertificate(string $certificateId, string $password = \'\'): void')
->toContain("\$this->authorize('update', \$this->server)")
->toContain(GetTraefikCertificates::class)
->toContain(DeleteTraefikCertificate::class);
});
it('uses bounded reads and atomic restricted writes for the ACME file', function () {
$reader = file_get_contents(app_path('Actions/Proxy/GetTraefikCertificates.php'));
$writer = file_get_contents(app_path('Actions/Proxy/DeleteTraefikCertificate.php'));
expect($reader)
->toContain('MAX_FILE_SIZE_BYTES')
->toContain('head -c')
->toContain('base64')
->and($writer)
->toContain('umask 077')
->toContain('chmod 600')
->toContain('mv --')
->not->toContain('rm -f');
});
@@ -0,0 +1,22 @@
<?php
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('reads fresh instance settings in each queued job of a long-running worker', function () {
InstanceSettings::forceCreate(['id' => 0, 'is_dns_validation_enabled' => false]);
// The worker process memoizes the settings with once().
expect((bool) instanceSettings()->is_dns_validation_enabled)->toBeFalse();
// Another process (the web UI) enables the setting. Its model events do not reach this process.
DB::table('instance_settings')->where('id', 0)->update(['is_dns_validation_enabled' => true]);
dispatch(fn () => Cache::put('seen_dns_validation', (bool) instanceSettings()->is_dns_validation_enabled));
expect(Cache::get('seen_dns_validation'))->toBeTrue();
});
+3 -3
View File
@@ -189,17 +189,16 @@ it('deletes the managed dns record when removing a service domain by key with de
'provider_zone_id' => 'zone-1',
'name' => 'example.com',
]);
$record = ManagedDnsRecord::factory()->create([
$record = ManagedDnsRecord::factory()->owned()->create([
'team_id' => $this->team->id,
'integration_token_id' => $token->id,
'dns_provider_zone_id' => $zone->id,
'resource_type' => $this->apiApp->getMorphClass(),
'resource_id' => $this->apiApp->getKey(),
'provider_record_id' => 'record-1',
'type' => 'A',
'name' => 'api.example.com',
'content' => '203.0.113.10',
]);
$record->addReference($this->apiApp);
Http::fake(['https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/record-1' => Http::sequence()
->push(['success' => true, 'result' => [
@@ -207,6 +206,7 @@ it('deletes the managed dns record when removing a service domain by key with de
'type' => 'A',
'name' => 'api.example.com',
'content' => '203.0.113.10',
'comment' => $record->ownershipComment(),
]])
->push(['success' => true, 'result' => ['id' => 'record-1']])]);
+14 -4
View File
@@ -1,8 +1,8 @@
<?php
// Discussion #11833: the resource settings sidebar must not expand every group by
// default. Each grouped sidebar wires up the shared accordion so only the group
// containing the active page is open by default (client-side, via localStorage).
// Each grouped resource settings sidebar wires up the shared accordion. Every group
// is expanded by default; a group the user collapsed stays collapsed (client-side,
// via localStorage), except the group containing the active page.
$groupedSidebars = [
'application' => 'resources/views/components/application/configuration-sidebar.blade.php',
@@ -17,7 +17,7 @@ it('wires the collapsible accordion into every grouped settings sidebar', functi
expect($contents)
->toContain('settingsSidebarAccordion(') // shared Alpine data provider
->toContain('$activeGroup') // only the active group opens by default
->toContain('$activeGroup') // the active group is always open
->toContain('nav-section-toggle') // group header is a toggle button
->toContain('toggle(') // header collapses/expands the group
->toContain("? 'xl:block' : 'xl:hidden'"); // desktop-only collapse wrapper
@@ -66,3 +66,13 @@ it('keeps the group for the active page open', function () {
->toContain('if (group === this.activeGroup)')
->toContain('return true;');
});
it('expands every group by default when the user has not collapsed it', function () {
$accordion = file_get_contents(base_path('resources/js/settings-sidebar-accordion.js'));
$isOpen = str($accordion)->after('isOpen(group) {')->before('toggle(group)')->toString();
expect($isOpen)
->toContain('return this.groups[group];')
->not->toContain('return false;');
});
+172
View File
@@ -0,0 +1,172 @@
<?php
use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Livewire\Project\Database\Heading as DatabaseHeading;
use App\Livewire\Project\Service\Heading as ServiceHeading;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use App\Support\ResourceStartActivity;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'docker']
);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'db',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'db',
'image' => 'postgres:17',
'status' => 'exited',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->service = Service::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'server_id' => $this->server->id,
]);
});
function startActivity(string $typeUuid, ProcessStatus $status, ?string $operation = null, int $minutesSinceLastUpdate = 0): Activity
{
$activity = activity()
->withProperties(array_filter([
'type' => ActivityTypes::INLINE->value,
'type_uuid' => $typeUuid,
'status' => $status->value,
'operation' => $operation,
]))
->event(ActivityTypes::INLINE->value)
->log('[]');
Activity::query()->whereKey($activity->id)->update([
'created_at' => now()->subMinutes($minutesSinceLastUpdate),
'updated_at' => now()->subMinutes($minutesSinceLastUpdate),
]);
return $activity->refresh();
}
function databaseHeadingFor(StandalonePostgresql $database): DatabaseHeading
{
$heading = new DatabaseHeading;
$heading->database = $database;
return $heading;
}
function serviceHeadingFor(Service $service): ServiceHeading
{
$heading = new ServiceHeading;
$heading->service = $service;
return $heading;
}
dataset('blocking start activities', [
'freshly queued' => [ProcessStatus::QUEUED, 0],
'queued for 9 minutes' => [ProcessStatus::QUEUED, 9],
'in progress with recent output' => [ProcessStatus::IN_PROGRESS, 0],
'in progress, silent for 14 minutes' => [ProcessStatus::IN_PROGRESS, 14],
]);
dataset('stale start activities', [
'queued for 11 minutes' => [ProcessStatus::QUEUED, 11],
'queued for a day' => [ProcessStatus::QUEUED, 60 * 24],
'in progress, silent for 16 minutes' => [ProcessStatus::IN_PROGRESS, 16],
]);
it('blocks database start and restart while a live start activity exists', function (ProcessStatus $status, int $minutes) {
$activity = startActivity($this->database->uuid, $status, 'database-start', $minutes);
$heading = databaseHeadingFor($this->database);
expect($heading->checkDeployments())->toBeTrue()
->and($heading->runningActivityId)->toBe($activity->id);
})->with('blocking start activities');
it('does not let a stale start activity block database start and restart', function (ProcessStatus $status, int $minutes) {
startActivity($this->database->uuid, $status, 'database-start', $minutes);
$heading = databaseHeadingFor($this->database);
expect($heading->checkDeployments())->toBeFalse()
->and($heading->runningActivityId)->toBeNull();
})->with('stale start activities');
it('does not block database start once the latest activity finished', function () {
startActivity($this->database->uuid, ProcessStatus::FINISHED, 'database-start');
expect(databaseHeadingFor($this->database)->checkDeployments())->toBeFalse();
});
it('reports a service as starting only while its start activity is live', function (ProcessStatus $status, int $minutes) {
startActivity($this->service->uuid, $status, minutesSinceLastUpdate: $minutes);
expect($this->service->isStarting())->toBeTrue()
->and(serviceHeadingFor($this->service)->checkDeployments())->toBeTrue();
})->with('blocking start activities');
it('does not report a service as starting because of a stale start activity', function (ProcessStatus $status, int $minutes) {
startActivity($this->service->uuid, $status, minutesSinceLastUpdate: $minutes);
expect($this->service->isStarting())->toBeFalse()
->and(serviceHeadingFor($this->service)->checkDeployments())->toBeFalse();
})->with('stale start activities');
it('marks interrupted database and service start activities as failed on startup', function () {
$queuedDatabaseStart = startActivity($this->database->uuid, ProcessStatus::QUEUED, 'database-start');
$runningDatabaseStart = startActivity((string) Str::uuid(), ProcessStatus::IN_PROGRESS, 'database-start');
$runningServiceStart = startActivity($this->service->uuid, ProcessStatus::IN_PROGRESS);
$finishedDatabaseStart = startActivity($this->database->uuid, ProcessStatus::FINISHED, 'database-start');
$failedDatabaseStart = startActivity($this->database->uuid, ProcessStatus::ERROR, 'database-start');
$unrelatedRunningProcess = startActivity('not-a-start-resource', ProcessStatus::IN_PROGRESS);
expect(ResourceStartActivity::failInterrupted())->toBe(3);
foreach ([$queuedDatabaseStart, $runningDatabaseStart, $runningServiceStart] as $activity) {
$activity->refresh();
expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
->and(data_get($activity, 'properties.error'))->toBe('Interrupted by a Coolify restart.')
->and(data_get($activity, 'properties.exitCode'))->toBe(1);
}
expect(data_get($finishedDatabaseStart->refresh(), 'properties.status'))->toBe(ProcessStatus::FINISHED->value)
->and(data_get($finishedDatabaseStart, 'properties.error'))->toBeNull()
->and(data_get($failedDatabaseStart->refresh(), 'properties.status'))->toBe(ProcessStatus::ERROR->value)
->and(data_get($failedDatabaseStart, 'properties.error'))->toBeNull()
->and(data_get($unrelatedRunningProcess->refresh(), 'properties.status'))->toBe(ProcessStatus::IN_PROGRESS->value);
});
it('runs the interrupted start cleanup from app:init', function () {
$source = file_get_contents(app_path('Console/Commands/Init.php'));
expect($source)->toContain('ResourceStartActivity::failInterrupted()');
});
+166 -2
View File
@@ -9,16 +9,180 @@ use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
uses(RefreshDatabase::class);
test('manual webhook routes are rate limited', function (string $provider) {
test('manual webhook routes are not rate limited per request', function (string $provider) {
$route = Route::getRoutes()->match(Request::create("/webhooks/source/{$provider}/events/manual", 'POST'));
expect($route->gatherMiddleware())->toContain('throttle:60,1');
expect(collect($route->gatherMiddleware())->filter(fn (mixed $middleware): bool => is_string($middleware) && str_starts_with($middleware, 'throttle')))->toBeEmpty();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
function sendManualWebhookPush(TestCase $test, string $provider, Application $application, bool $validSignature = true, string $ip = '203.0.113.10', string $repository = 'test-org/test-repo'): TestResponse
{
$secret = $validSignature ? $application->{"manual_webhook_secret_{$provider}"} : 'wrong-secret';
$server = ['REMOTE_ADDR' => $ip, 'CONTENT_TYPE' => 'application/json'];
if ($provider === 'gitlab') {
$payload = json_encode([
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => $repository],
'after' => 'abc123',
'commits' => [],
]);
return $test->call('POST', '/webhooks/source/gitlab/events/manual', [], [], [], $server + [
'HTTP_X-Gitlab-Token' => $secret,
], $payload);
}
if ($provider === 'bitbucket') {
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
'repository' => ['full_name' => $repository],
]);
return $test->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], $server + [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, $secret),
], $payload);
}
$payload = json_encode([
'ref' => 'refs/heads/main',
'repository' => ['full_name' => $repository],
'after' => 'abc123',
'commits' => [],
]);
$eventHeader = $provider === 'github' ? 'HTTP_X-GitHub-Event' : 'HTTP_X-Gitea-Event';
return $test->call('POST', "/webhooks/source/{$provider}/events/manual", [], [], [], $server + [
$eventHeader => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
], $payload);
}
describe('Manual Webhook Failed Authentication Rate Limiting', function () {
test('valid signed deliveries are never throttled', function (string $provider) {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 80; $i++) {
$response = sendManualWebhookPush($this, $provider, $application);
expect($response->getStatusCode())->toBe(200);
expect($response->getContent())->not->toContain('Invalid signature');
}
expect(RateLimiter::attempts("manual-webhook-failures:{$provider}:203.0.113.10"))->toBe(0);
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('repeated invalid signatures are throttled after 30 failures', function (string $provider) {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
$response = sendManualWebhookPush($this, $provider, $application, validSignature: false);
$response->assertOk();
expect($response->getContent())->toContain('Invalid signature');
}
$response = sendManualWebhookPush($this, $provider, $application, validSignature: false);
$response->assertStatus(429);
$response->assertHeader('Retry-After');
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('valid deliveries do not count when another matching application has a different secret', function () {
$application = createApplicationWithWebhook();
createApplicationWithWebhook(overrides: ['name' => 'second-webhook-test-app']);
for ($i = 0; $i < 35; $i++) {
$response = sendManualWebhookPush($this, 'github', $application);
$response->assertOk();
expect($response->getContent())->not->toContain('Invalid signature');
}
expect(RateLimiter::attempts('manual-webhook-failures:github:203.0.113.10'))->toBe(0);
});
test('deliveries for unknown repositories count as failed authentication', function () {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
sendManualWebhookPush($this, 'github', $application, repository: 'unknown-org/unknown-repo')->assertOk();
}
sendManualWebhookPush($this, 'github', $application)->assertStatus(429);
});
test('gitlab deliveries without a token count as failed authentication', function () {
createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
$this->postJson('/webhooks/source/gitlab/events/manual', [
'object_kind' => 'push',
'ref' => 'refs/heads/main',
'project' => ['path_with_namespace' => 'test-org/test-repo'],
])->assertOk();
}
expect(RateLimiter::tooManyAttempts('manual-webhook-failures:gitlab:127.0.0.1', 30))->toBeTrue();
});
test('ping deliveries do not count as failures', function () {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 40; $i++) {
$this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_X-GitHub-Event' => 'ping',
'CONTENT_TYPE' => 'application/json',
], '{}')->assertOk();
}
$response = sendManualWebhookPush($this, 'github', $application);
$response->assertOk();
expect($response->getContent())->not->toContain('Invalid signature');
});
test('failures on one provider do not block another provider', function () {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
sendManualWebhookPush($this, 'github', $application, validSignature: false);
}
sendManualWebhookPush($this, 'github', $application, validSignature: false)->assertStatus(429);
foreach (['gitlab', 'bitbucket', 'gitea'] as $provider) {
$response = sendManualWebhookPush($this, $provider, $application);
$response->assertOk();
expect($response->getContent())->not->toContain('Invalid signature');
}
});
test('valid deliveries from a different client IP are not blocked', function () {
$application = createApplicationWithWebhook();
for ($i = 0; $i < 30; $i++) {
sendManualWebhookPush($this, 'github', $application, validSignature: false, ip: '198.51.100.7');
}
sendManualWebhookPush($this, 'github', $application, validSignature: false, ip: '198.51.100.7')->assertStatus(429);
$response = sendManualWebhookPush($this, 'github', $application, ip: '203.0.113.10');
$response->assertOk();
expect($response->getContent())->not->toContain('Invalid signature');
});
});
function createApplicationWithWebhook(string $repo = 'test-org/test-repo', string $branch = 'main', array $overrides = []): Application
{
$team = Team::factory()->create();
+19 -21
View File
@@ -2,7 +2,7 @@
use App\Jobs\ApplicationDeploymentJob;
function resolveComposeDockerfilePath(string|array $build): string
function resolveComposeDockerfilePath(mixed $build): ?string
{
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$method = new ReflectionMethod(ApplicationDeploymentJob::class, 'resolveComposeDockerfilePath');
@@ -11,31 +11,29 @@ function resolveComposeDockerfilePath(string|array $build): string
}
test('compose build paths resolve for short and long syntax', function (string|array $build, string $expected) {
// The path is not trusted here: the ARG injection step quotes it and confines the resolved file.
expect(resolveComposeDockerfilePath($build))->toBe($expected);
})->with([
'short syntax' => ['services/api', 'services/api/Dockerfile'],
'current directory short syntax' => ['.', 'Dockerfile'],
'current directory short syntax' => ['.', './Dockerfile'],
'long syntax defaults' => [['context' => 'services/api'], 'services/api/Dockerfile'],
'nested Dockerfile' => [['context' => './services/api', 'dockerfile' => 'docker/prod.Dockerfile'], 'services/api/docker/prod.Dockerfile'],
'standard Dockerfile variants' => [['context' => '.', 'dockerfile' => 'Dockerfile.prod'], 'Dockerfile.prod'],
'traversal that stays in repository' => [['context' => 'services/api', 'dockerfile' => '../Dockerfile'], 'services/Dockerfile'],
'nested Dockerfile' => [['context' => './services/api', 'dockerfile' => 'docker/prod.Dockerfile'], './services/api/docker/prod.Dockerfile'],
'standard Dockerfile variants' => [['context' => '.', 'dockerfile' => 'Dockerfile.prod'], './Dockerfile.prod'],
'monorepo context above the base directory' => [['context' => '..'], '../Dockerfile'],
'path with a space' => ['my app', 'my app/Dockerfile'],
'absolute Dockerfile' => [['context' => '.', 'dockerfile' => '/srv/app/Dockerfile'], '/srv/app/Dockerfile'],
]);
test('compose build paths reject repository escape and shell command injection', function (string|array $build) {
expect(fn () => resolveComposeDockerfilePath($build))
->toThrow(RuntimeException::class);
test('compose build contexts that Coolify cannot inspect locally are skipped', function (mixed $build) {
expect(resolveComposeDockerfilePath($build))->toBeNull();
})->with([
'short syntax semicolon' => ['.; touch /tmp/short-context-pwned'],
'short syntax command substitution' => ['$(touch /tmp/short-context-pwned)'],
'context semicolon' => [['context' => '.; touch /tmp/context-pwned', 'dockerfile' => 'Dockerfile']],
'dockerfile semicolon' => [['context' => '.', 'dockerfile' => 'Dockerfile; touch /tmp/dockerfile-pwned']],
'context command substitution' => [['context' => '$(touch /tmp/context-pwned)', 'dockerfile' => 'Dockerfile']],
'dockerfile command substitution' => [['context' => '.', 'dockerfile' => '$(touch /tmp/dockerfile-pwned)']],
'context newline' => [['context' => "services/api\ntouch /tmp/context-pwned", 'dockerfile' => 'Dockerfile']],
'dockerfile newline' => [['context' => '.', 'dockerfile' => "Dockerfile\ntouch /tmp/dockerfile-pwned"]],
'context traversal' => [['context' => '../outside', 'dockerfile' => 'Dockerfile']],
'nested traversal' => [['context' => 'services/api', 'dockerfile' => '../../../outside.Dockerfile']],
'dockerfile traversal' => [['context' => '.', 'dockerfile' => '../outside.Dockerfile']],
'context absolute path' => [['context' => '/tmp', 'dockerfile' => 'Dockerfile']],
'dockerfile absolute path' => [['context' => '.', 'dockerfile' => '/tmp/Dockerfile']],
'git URL' => ['https://github.com/coollabsio/coolify.git#main:docker'],
'git SSH URL' => [['context' => 'git@github.com:coollabsio/coolify.git']],
'context variable' => ['${APP_DIR:-.}'],
'Dockerfile variable' => [['context' => '.', 'dockerfile' => '${DOCKERFILE}']],
'command substitution' => [['context' => '$(touch /tmp/context-pwned)']],
'inline Dockerfile' => [['context' => '.', 'dockerfile_inline' => "FROM alpine\n"]],
'empty context' => [''],
'non-string context' => [['context' => ['nested']]],
'invalid build definition' => [42],
]);
@@ -0,0 +1,72 @@
<?php
use Symfony\Component\Process\Process;
function installScriptUiHelpers(string $path): string
{
$script = file_get_contents(dirname(__DIR__, 2).'/'.$path);
$start = strpos($script, '# Terminal UI');
$end = strpos($script, '# fd 3 is the terminal');
return substr($script, $start, $end - $start);
}
it('shows every installer step with the terminal UI and sends command output to the log', function (string $path) {
$script = file_get_contents(dirname(__DIR__, 2).'/'.$path);
expect($script)
->toContain('TOTAL_STEPS=9')
->toContain('exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1')
->toContain('trap ui_on_exit EXIT')
->toContain('"true" 3>&-')
->not->toContain('tee -a $INSTALLATION_LOG_WITH_DATE')
->not->toContain('getAJoke')
->and(substr_count($script, 'step_start "'))->toBe(10)
->and(preg_match_all('/^step_done(?!\()/m', $script))->toBe(9);
})->with([
'stable install script' => ['scripts/install.sh'],
'nightly install script' => ['other/nightly/install.sh'],
]);
it('renders aligned step lines, warnings and failures without a terminal', function (string $path) {
$demo = installScriptUiHelpers($path).<<<'BASH'
set -e
log() { :; }
log_section() { :; }
INSTALLATION_LOG_WITH_DATE=$(mktemp)
exec 3>&1
exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1
trap ui_on_exit EXIT
step_start "Installing required packages" "curl wget git jq openssl"
step_done
step_start "Pulling images" "coolify · postgres · redis · helper"
warn "SSH PermitRootLogin is disabled."
step_done
step_start "Starting Coolify" "v4.0.0"
echo "ERROR: Coolify container is not healthy"
exit 1
BASH;
$process = new Process(['bash', '-c', $demo], env: ['LC_ALL' => 'C', 'TERM' => 'dumb']);
$process->run();
$output = $process->getOutput();
$stepLines = array_values(array_filter(explode("\n", $output), fn (string $line) => str_contains($line, '/9 ')));
expect($process->getExitCode())->toBe(1)
->and($output)->not->toContain("\033[")
->toContain('1/9 Installing required packages curl wget git jq openssl')
->toContain('2/9 Pulling images coolify · postgres · redis · helper')
->toContain('! SSH PermitRootLogin is disabled.')
->toContain('3/9 Starting Coolify v4.0.0')
->toContain('Installation failed.')
->toContain('ERROR: Coolify container is not healthy')
->and($stepLines)->toHaveCount(3)
->and($stepLines[0])->toEndWith('✓')
->and($stepLines[1])->toEndWith('✓')
->and($stepLines[2])->toEndWith('✗')
->and(array_map(fn (string $line) => mb_strlen($line), $stepLines))->each->toBe(80);
})->with([
'stable install script' => ['scripts/install.sh'],
'nightly install script' => ['other/nightly/install.sh'],
]);
+75
View File
@@ -0,0 +1,75 @@
<?php
use App\Services\TraefikAcmeService;
function traefikAcmeFixture(): string
{
return json_encode([
'letsencrypt' => [
'Account' => ['Email' => 'admin@example.com'],
'Certificates' => [
[
'domain' => [
'main' => 'example.com',
'sans' => ['www.example.com', '*.example.com'],
],
'certificate' => base64_encode('first-certificate'),
'key' => base64_encode('first-key'),
'Store' => 'default',
],
[
'domain' => ['main' => 'api.example.net'],
'certificate' => base64_encode('second-certificate'),
'key' => base64_encode('second-key'),
],
],
],
'zerossl' => [
'Account' => ['Email' => 'admin@example.org'],
'Certificates' => [[
'domain' => ['main' => 'example.org', 'sans' => []],
'certificate' => base64_encode('third-certificate'),
'key' => base64_encode('third-key'),
]],
],
], JSON_THROW_ON_ERROR);
}
it('lists every certificate from every ACME resolver without exposing secrets', function () {
$certificates = app(TraefikAcmeService::class)->certificates(traefikAcmeFixture());
expect($certificates)->toHaveCount(3)
->and($certificates[0])->toMatchArray([
'resolver' => 'letsencrypt',
'main_domain' => 'example.com',
'sans' => ['www.example.com', '*.example.com'],
'store' => 'default',
])
->and($certificates[0])->toHaveKeys(['id', 'expires_at'])
->and($certificates[0])->not->toHaveKeys(['certificate', 'key', 'account']);
});
it('removes only the selected certificate and preserves all other ACME data', function () {
$service = app(TraefikAcmeService::class);
$certificates = $service->certificates(traefikAcmeFixture());
$updated = json_decode($service->deleteCertificate(
traefikAcmeFixture(),
'letsencrypt',
$certificates[0]['id'],
), true, flags: JSON_THROW_ON_ERROR);
expect($updated['letsencrypt']['Account']['Email'])->toBe('admin@example.com')
->and($updated['letsencrypt']['Certificates'])->toHaveCount(1)
->and($updated['letsencrypt']['Certificates'][0]['domain']['main'])->toBe('api.example.net')
->and($updated['zerossl']['Certificates'])->toHaveCount(1);
});
it('rejects invalid files and unknown certificate identifiers', function () {
$service = app(TraefikAcmeService::class);
expect(fn () => $service->certificates('{invalid'))
->toThrow(RuntimeException::class, 'invalid JSON')
->and(fn () => $service->deleteCertificate(traefikAcmeFixture(), 'letsencrypt', 'missing'))
->toThrow(RuntimeException::class, 'could not be found');
});