mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 17:01:49 -04:00
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class Handler extends ExceptionHandler
|
||||
ProcessException::class,
|
||||
NonReportableException::class,
|
||||
DeploymentException::class,
|
||||
DatabaseStartException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'))");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '.'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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']),
|
||||
]);
|
||||
|
||||
@@ -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 <b>denied</b>')
|
||||
->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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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']])]);
|
||||
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
|
||||
@@ -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()');
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user