mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
chore: merge main into release/v4.5 (#12140)
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
- Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`.
|
||||
- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error.
|
||||
- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only.
|
||||
- Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree.
|
||||
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
|
||||
|
||||
## Verify the complete user flow
|
||||
|
||||
@@ -117,6 +117,7 @@ Thank you so much!
|
||||
* [dataforest Cloud](https://cloud.dataforest.net/en) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
|
||||
* [ISHosting](https://ishosting.com/) - Hosting and VPS solutions
|
||||
* [QuickSrv](https://quicksrv.io/) - Fast and reliable server hosting
|
||||
* [NodePlus.ai](https://nodeplus.ai/) - AI that reads every system your business runs on
|
||||
|
||||
### Small Sponsors
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
@@ -36,6 +37,7 @@ class StartClickhouse
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
|
||||
@@ -178,15 +180,15 @@ class StartClickhouse
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_USER')) {
|
||||
$environment_variables->push("CLICKHOUSE_USER={$this->database->clickhouse_admin_user}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_PASSWORD')) {
|
||||
$environment_variables->push("CLICKHOUSE_PASSWORD={$this->database->clickhouse_admin_password}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_DB'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_DB')) {
|
||||
$environment_variables->push("CLICKHOUSE_DB={$this->database->clickhouse_db}");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
@@ -58,6 +59,8 @@ class StartDatabaseProxy
|
||||
$configuration_dir = database_proxy_dir($database->uuid);
|
||||
$host_configuration_dir = devHostDockerPath($server, $configuration_dir);
|
||||
$timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout);
|
||||
$listenConfig = $this->buildListenConfig($database->public_port, $this->isNetworkIpv6Enabled($network, $server));
|
||||
$upstreamConfig = $this->buildUpstreamConfig($containerName, $internalPort);
|
||||
$nginxconf = <<<EOF
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
@@ -68,9 +71,10 @@ class StartDatabaseProxy
|
||||
worker_connections 1024;
|
||||
}
|
||||
stream {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
server {
|
||||
listen $database->public_port;
|
||||
proxy_pass $containerName:$internalPort;
|
||||
$listenConfig
|
||||
$upstreamConfig
|
||||
$timeoutConfig
|
||||
}
|
||||
}
|
||||
@@ -164,6 +168,36 @@ class StartDatabaseProxy
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker publishes the port on [::] too and forwards it to the container's IPv6 address
|
||||
* when the network has IPv6 enabled, so nginx must listen there as well.
|
||||
*/
|
||||
private function isNetworkIpv6Enabled(string $network, Server $server): bool
|
||||
{
|
||||
$safeNetwork = escapeshellarg($network);
|
||||
$output = instant_remote_process(["docker network inspect {$safeNetwork} --format '{{.EnableIPv6}}'"], $server, false);
|
||||
|
||||
return trim((string) $output) === 'true';
|
||||
}
|
||||
|
||||
private function buildListenConfig(int $port, bool $ipv6Enabled): string
|
||||
{
|
||||
if (! $ipv6Enabled) {
|
||||
return "listen {$port};";
|
||||
}
|
||||
|
||||
return "listen {$port};\n listen [::]:{$port};";
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream is a variable so nginx resolves it through Docker's DNS on new connections.
|
||||
* A literal host is resolved only once at startup, so a restarted database with a new IP would break the proxy.
|
||||
*/
|
||||
private function buildUpstreamConfig(string $host, int $port): string
|
||||
{
|
||||
return "set \$upstream {$host}:{$port};\n proxy_pass \$upstream;";
|
||||
}
|
||||
|
||||
private function buildProxyTimeoutConfig(?int $timeout): string
|
||||
{
|
||||
if ($timeout === null || $timeout < 1) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
@@ -87,6 +88,7 @@ class StartDragonfly
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
$startCommand = $this->buildStartCommand();
|
||||
@@ -263,7 +265,7 @@ class StartDragonfly
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'REDIS_PASSWORD')) {
|
||||
$environment_variables->push("REDIS_PASSWORD={$this->database->dragonfly_password}");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
@@ -87,6 +88,7 @@ class StartKeydb
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
$this->add_custom_keydb();
|
||||
@@ -263,7 +265,7 @@ class StartKeydb
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'REDIS_PASSWORD')) {
|
||||
$environment_variables->push("REDIS_PASSWORD={$this->database->keydb_password}");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
@@ -82,6 +83,7 @@ class StartMariadb
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
$this->add_custom_mysql();
|
||||
@@ -251,18 +253,18 @@ class StartMariadb
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_ROOT_PASSWORD')) {
|
||||
$environment_variables->push("MARIADB_ROOT_PASSWORD={$this->database->mariadb_root_password}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_DATABASE'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_DATABASE')) {
|
||||
$environment_variables->push("MARIADB_DATABASE={$this->database->mariadb_database}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_USER'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_USER')) {
|
||||
$environment_variables->push("MARIADB_USER={$this->database->mariadb_user}");
|
||||
}
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_PASSWORD')) {
|
||||
$environment_variables->push("MARIADB_PASSWORD={$this->database->mariadb_password}");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
@@ -89,6 +90,7 @@ class StartMongodb
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
$this->add_custom_mongo_conf();
|
||||
@@ -311,15 +313,15 @@ class StartMongodb
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_ROOT_USERNAME')) {
|
||||
$environment_variables->push("MONGO_INITDB_ROOT_USERNAME={$this->database->mongo_initdb_root_username}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_ROOT_PASSWORD')) {
|
||||
$environment_variables->push("MONGO_INITDB_ROOT_PASSWORD={$this->database->mongo_initdb_root_password}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_DATABASE'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_DATABASE')) {
|
||||
$environment_variables->push("MONGO_INITDB_DATABASE={$this->database->mongo_initdb_database}");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
@@ -84,6 +85,7 @@ class StartMysql
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
$this->add_custom_mysql();
|
||||
@@ -263,18 +265,18 @@ class StartMysql
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_ROOT_PASSWORD')) {
|
||||
$environment_variables->push("MYSQL_ROOT_PASSWORD={$this->database->mysql_root_password}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_DATABASE'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_DATABASE')) {
|
||||
$environment_variables->push("MYSQL_DATABASE={$this->database->mysql_database}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_USER'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_USER')) {
|
||||
$environment_variables->push("MYSQL_USER={$this->database->mysql_user}");
|
||||
}
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_PASSWORD')) {
|
||||
$environment_variables->push("MYSQL_PASSWORD={$this->database->mysql_password}");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
@@ -88,6 +89,7 @@ class StartPostgresql
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
$this->generate_init_scripts();
|
||||
@@ -274,18 +276,18 @@ class StartPostgresql
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_USER')) {
|
||||
$environment_variables->push("POSTGRES_USER={$this->database->postgres_user}");
|
||||
}
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('PGUSER'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'PGUSER')) {
|
||||
$environment_variables->push("PGUSER={$this->database->postgres_user}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_PASSWORD'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_PASSWORD')) {
|
||||
$environment_variables->push("POSTGRES_PASSWORD={$this->database->postgres_password}");
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_DB'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_DB')) {
|
||||
$environment_variables->push("POSTGRES_DB={$this->database->postgres_db}");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
@@ -86,6 +87,7 @@ class StartRedis
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
$this->add_custom_redis();
|
||||
@@ -268,10 +270,6 @@ class StartRedis
|
||||
|
||||
if ($env->key === 'REDIS_USERNAME') {
|
||||
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
|
||||
if (! $usesSecretManager) {
|
||||
$this->database->update(['redis_username' => $this->resolvedRedisUsername]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Actions\Shared\EnsureContentFilesOnServer;
|
||||
use App\Models\StandaloneSqlite;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
@@ -32,6 +33,7 @@ class StartSqlite
|
||||
|
||||
$persistent_storages = $this->generate_local_persistent_volumes();
|
||||
$persistent_file_volumes = $this->database->fileStorages()->get();
|
||||
array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server));
|
||||
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
||||
$environment_variables = $this->generate_environment_variables();
|
||||
|
||||
@@ -147,7 +149,7 @@ class StartSqlite
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('SQLITE_DATABASES'))->isEmpty()) {
|
||||
if (! $this->hasEnvironmentVariable($environment_variables, 'SQLITE_DATABASES')) {
|
||||
$environment_variables->push("SQLITE_DATABASES={$this->database->sqlite_databases}");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Actions\Server;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Notifications\Server\UpgradeSkippedLowDiskSpace;
|
||||
use App\Services\CoolifyUpgradeDiskSpace;
|
||||
use App\Services\CoolifyUpgradeStatus;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -21,7 +23,7 @@ class UpdateCoolify
|
||||
|
||||
public ?string $currentVersion = null;
|
||||
|
||||
public function handle($manual_update = false)
|
||||
public function handle($manual_update = false, bool $skipDiskSpaceCheck = false)
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->backupDatabase();
|
||||
@@ -113,8 +115,12 @@ class UpdateCoolify
|
||||
);
|
||||
}
|
||||
|
||||
if (! $skipDiskSpaceCheck && ! $this->ensureEnoughDiskSpace($manual_update)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->backupDatabase();
|
||||
$this->update();
|
||||
$this->update($skipDiskSpaceCheck);
|
||||
$settings->new_version_available = false;
|
||||
$settings->save();
|
||||
}
|
||||
@@ -135,7 +141,7 @@ class UpdateCoolify
|
||||
}
|
||||
}
|
||||
|
||||
private function update()
|
||||
private function update(bool $skipDiskSpaceCheck)
|
||||
{
|
||||
$this->ensureNoUpgradeIsRunning();
|
||||
|
||||
@@ -143,15 +149,54 @@ class UpdateCoolify
|
||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||
$registryUrl = coolifyRegistryUrl();
|
||||
|
||||
$upgradeCommand = 'bash /data/coolify/source/upgrade.sh '.
|
||||
escapeshellarg($this->latestVersion).' '.
|
||||
escapeshellarg($latestHelperImageVersion).' '.
|
||||
escapeshellarg($registryUrl);
|
||||
if ($skipDiskSpaceCheck) {
|
||||
// Arguments 4 and 5: do not skip the backup, skip the disk space check.
|
||||
$upgradeCommand .= " 'false' 'true'";
|
||||
}
|
||||
|
||||
remote_process([
|
||||
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||
'bash /data/coolify/source/upgrade.sh '.
|
||||
escapeshellarg($this->latestVersion).' '.
|
||||
escapeshellarg($latestHelperImageVersion).' '.
|
||||
escapeshellarg($registryUrl),
|
||||
$upgradeCommand,
|
||||
], $this->server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic updates are skipped and the team is notified. Manual updates fail with an error.
|
||||
*/
|
||||
private function ensureEnoughDiskSpace(bool $manualUpdate): bool
|
||||
{
|
||||
$availableGb = $this->availableDiskSpaceGb();
|
||||
if (! CoolifyUpgradeDiskSpace::isLow($availableGb)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('Upgrade skipped because of low disk space', [
|
||||
'target_version' => $this->latestVersion,
|
||||
'available_gb' => $availableGb,
|
||||
'required_gb' => CoolifyUpgradeDiskSpace::REQUIRED_GB,
|
||||
'manual_update' => $manualUpdate,
|
||||
]);
|
||||
|
||||
if ($manualUpdate) {
|
||||
throw new \Exception(
|
||||
"Not enough free disk space to upgrade: {$availableGb} GB free, ".CoolifyUpgradeDiskSpace::REQUIRED_GB.' GB required.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->server->team?->notify(new UpgradeSkippedLowDiskSpace($this->server, $this->latestVersion, $availableGb));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function availableDiskSpaceGb(): ?float
|
||||
{
|
||||
return app(CoolifyUpgradeDiskSpace::class)->availableGb($this->server);
|
||||
}
|
||||
|
||||
private function ensureNoUpgradeIsRunning(): void
|
||||
{
|
||||
$status = $this->readUpgradeStatus();
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Actions\Shared;
|
||||
use App\Models\Server;
|
||||
use App\Support\DnsRecordHints;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
@@ -76,14 +75,9 @@ class CheckDomainDns
|
||||
}
|
||||
|
||||
try {
|
||||
$query = app()->make(DNSQuery::class, [
|
||||
'server' => $dnsServer,
|
||||
'port' => 53,
|
||||
'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)),
|
||||
]);
|
||||
$records = $query->query($host, $type);
|
||||
$records = queryDnsServer($dnsServer, $host, $type, min(5, (int) floor($remainingNanoseconds / 1_000_000_000)));
|
||||
|
||||
if ($records === false || $query->hasError()) {
|
||||
if ($records === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -4092,7 +4092,7 @@ class ApplicationsController extends Controller
|
||||
$is_shown_once = $item->get('is_shown_once') ?? false;
|
||||
$key = str($item->get('key'))->trim()->replace(' ', '_')->value;
|
||||
if ($is_preview) {
|
||||
$env = $application->environment_variables_preview->where('key', $key)->first();
|
||||
$env = $application->environment_variables_preview()->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $item->get('value');
|
||||
|
||||
@@ -4131,7 +4131,7 @@ class ApplicationsController extends Controller
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$env = $application->environment_variables->where('key', $key)->first();
|
||||
$env = $application->environment_variables()->where('key', $key)->first();
|
||||
if ($env) {
|
||||
$env->value = $item->get('value');
|
||||
if ($env->is_literal != $is_literal) {
|
||||
|
||||
@@ -932,7 +932,7 @@ class HetznerController extends Controller
|
||||
if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
|
||||
} elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||
$ipAddress = hetznerServerIpv6($hetznerServer['public_net']['ipv6']['ip']);
|
||||
}
|
||||
|
||||
if (! $ipAddress) {
|
||||
|
||||
@@ -358,11 +358,7 @@ class ServersController extends Controller
|
||||
if ($applications->count() > 0) {
|
||||
foreach ($applications as $application) {
|
||||
$ip = $application->destination->server->ip;
|
||||
$fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) {
|
||||
$f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/');
|
||||
|
||||
return str(str($f[0])->explode(':')[0]);
|
||||
})->filter(function (Stringable $fqdn) {
|
||||
$fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) {
|
||||
return $fqdn->isNotEmpty();
|
||||
});
|
||||
|
||||
@@ -401,11 +397,7 @@ class ServersController extends Controller
|
||||
$service_applications = $service->applications;
|
||||
if ($service_applications->count() > 0) {
|
||||
foreach ($service_applications as $application) {
|
||||
$fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) {
|
||||
$f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/');
|
||||
|
||||
return str(str($f[0])->explode(':')[0]);
|
||||
})->filter(function (Stringable $fqdn) {
|
||||
$fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) {
|
||||
return $fqdn->isNotEmpty();
|
||||
});
|
||||
$serviceIp = $server->ip;
|
||||
@@ -597,7 +589,7 @@ class ServersController extends Controller
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
$foundServer = ModelsServer::whereIp($request->ip)->first();
|
||||
$foundServer = ModelsServer::whereIp(ModelsServer::normalizeIp($request->ip))->first();
|
||||
if ($foundServer) {
|
||||
return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400);
|
||||
}
|
||||
@@ -1125,4 +1117,17 @@ class ServersController extends Controller
|
||||
|
||||
return response()->json(['message' => $message], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com".
|
||||
*/
|
||||
private static function domainHostWithoutPort(string $fqdn): Stringable
|
||||
{
|
||||
$authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0];
|
||||
if (str_starts_with($authority, '[') && str_contains($authority, ']')) {
|
||||
return str(substr($authority, 0, strpos($authority, ']') + 1));
|
||||
}
|
||||
|
||||
return str(str($authority)->explode(':')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Github extends Controller
|
||||
@@ -747,11 +748,21 @@ class Github extends Controller
|
||||
action: 'install',
|
||||
);
|
||||
|
||||
abort_unless(
|
||||
$this->githubInstallationBelongsToApp($github_app, $installation_id),
|
||||
403,
|
||||
'GitHub App installation could not be verified.'
|
||||
);
|
||||
$verification_error = $this->githubInstallationVerificationError($github_app, $installation_id);
|
||||
if ($verification_error) {
|
||||
Log::warning('GitHub App installation could not be verified.', [
|
||||
'github_app_id' => $github_app->id,
|
||||
'installation_id' => $installation_id,
|
||||
'reason' => $verification_error,
|
||||
]);
|
||||
|
||||
$message = "GitHub App installation could not be verified: {$verification_error}";
|
||||
abort_if($request->expectsJson(), 403, $message);
|
||||
|
||||
return redirect()
|
||||
->route('source.github.show', ['github_app_uuid' => $github_app->uuid])
|
||||
->with('error', $message);
|
||||
}
|
||||
|
||||
$github_app->installation_id = $installation_id;
|
||||
$github_app->save();
|
||||
@@ -778,11 +789,13 @@ class Github extends Controller
|
||||
* The installation id arrives as an untrusted query parameter on an
|
||||
* unauthenticated-reachable GET callback, so it must be confirmed against
|
||||
* the GitHub API using the App's own credentials before it is persisted.
|
||||
*
|
||||
* @return string|null The reason the verification failed, or null when it passed.
|
||||
*/
|
||||
private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
|
||||
private function githubInstallationVerificationError(GithubApp $github_app, string $installation_id): ?string
|
||||
{
|
||||
if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
|
||||
return false;
|
||||
if (blank($github_app->app_id) || blank(githubAppPrivateKey($github_app)?->private_key)) {
|
||||
return 'The GitHub App ID or private key is missing.';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -794,12 +807,19 @@ class Github extends Controller
|
||||
->timeout(10)
|
||||
->connectTimeout(5)
|
||||
->get("{$github_app->api_url}/app/installations/{$installation_id}");
|
||||
|
||||
return $response->successful()
|
||||
&& (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
return strip_tags(str_replace('<br>', ' ', $e->getMessage()));
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return "GitHub API returned HTTP {$response->status()}: ".data_get($response->json(), 'message', 'no error message found');
|
||||
}
|
||||
|
||||
if ((string) data_get($response->json(), 'app_id') !== (string) $github_app->app_id) {
|
||||
return 'The installation belongs to a different GitHub App.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
|
||||
@@ -813,12 +833,20 @@ class Github extends Controller
|
||||
$this->rejectInvalidGithubAppSetupState($request);
|
||||
}
|
||||
|
||||
$team_id = $request->user()?->currentTeam()?->id;
|
||||
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
|
||||
// The state is bound to the team that started the setup. The user may have switched
|
||||
// teams while on GitHub, so check membership of that team, not the session team.
|
||||
$team_id = (int) data_get($payload, 'team_id');
|
||||
abort_unless((bool) $request->user()?->teams->contains('id', $team_id), 403);
|
||||
|
||||
return GithubApp::whereKey(data_get($payload, 'github_app_id'))
|
||||
->where('team_id', data_get($payload, 'team_id'))
|
||||
$github_app = GithubApp::whereKey(data_get($payload, 'github_app_id'))
|
||||
->where('team_id', $team_id)
|
||||
->firstOrFail();
|
||||
|
||||
if ($request->user()->currentTeam()?->id !== $team_id) {
|
||||
refreshSession($github_app->team);
|
||||
}
|
||||
|
||||
return $github_app;
|
||||
}
|
||||
|
||||
private function rejectInvalidGithubAppSetupState(Request $request): never
|
||||
|
||||
@@ -1332,7 +1332,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->application_deployment_queue->addLogEntry("Pushing image to docker registry ({$this->production_image_name}).");
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "docker push {$this->production_image_name}"),
|
||||
executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg($this->production_image_name)),
|
||||
'hidden' => true,
|
||||
],
|
||||
);
|
||||
@@ -1341,12 +1341,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "docker tag {$this->production_image_name} {$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}"),
|
||||
executeInDocker($this->deployment_uuid, 'docker tag '.escapeshellarg($this->production_image_name).' '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")),
|
||||
'ignore_errors' => true,
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "docker push {$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}"),
|
||||
executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")),
|
||||
'ignore_errors' => true,
|
||||
'hidden' => true,
|
||||
],
|
||||
@@ -1552,7 +1552,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
]);
|
||||
if (str($this->saved_outputs->get('local_image_found'))->isEmpty() && $this->application->docker_registry_image_name) {
|
||||
$this->execute_remote_command([
|
||||
"docker pull {$this->production_image_name} 2>/dev/null",
|
||||
'docker pull '.escapeshellarg($this->production_image_name).' 2>/dev/null',
|
||||
'ignore_errors' => true,
|
||||
'hidden' => true,
|
||||
]);
|
||||
@@ -3478,7 +3478,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
$environmentPrefix = $this->railpack_build_environment_prefix($controlVariables);
|
||||
$secretFlags = $this->railpack_build_secret_flags($variables);
|
||||
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
|
||||
// The helper image sets RAILPACK_VERSION, so the frontend always matches the Railpack CLI in the helper.
|
||||
// Helper images without it fall back to the version of the Railpack CLI itself.
|
||||
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION:-$(railpack --version | cut -d " " -f 3)}';
|
||||
|
||||
$buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack"
|
||||
." {$this->addHosts} --network host"
|
||||
@@ -3943,6 +3945,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
$labels = collect(generateLabelsApplication($this->application, $this->preview));
|
||||
}
|
||||
$labels = $this->useDestinationNetworkInCaddyLabels($labels);
|
||||
$labels = traefikHostRulesWithoutIpv6Brackets($labels);
|
||||
if ($this->application->settings->is_container_label_escape_enabled) {
|
||||
$labels = $labels->map(function ($value, $key) {
|
||||
return escapeDollarSign($value);
|
||||
@@ -4244,7 +4247,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
|
||||
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
|
||||
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
|
||||
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
|
||||
$host = ValidationPatterns::isValidHealthCheckHost($this->application->health_check_host)
|
||||
? formatHostForUrl($this->application->health_check_host)
|
||||
: 'localhost';
|
||||
$path = $this->application->health_check_path
|
||||
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/')
|
||||
: null;
|
||||
|
||||
@@ -922,7 +922,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
|
||||
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}";
|
||||
instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
|
||||
@@ -403,7 +403,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
.escapeshellarg($backupLocation.':'.$backupLocation.':ro').' '.escapeshellarg($image),
|
||||
'docker exec '.escapeshellarg($containerName).' mc alias set'.$resolveOptions.' temporary '
|
||||
.escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret),
|
||||
'docker exec '.escapeshellarg($containerName).' mc cp '.escapeshellarg($backupLocation).' '
|
||||
'docker exec '.escapeshellarg($containerName).' mc cp'.$resolveOptions.' '.escapeshellarg($backupLocation).' '
|
||||
.escapeshellarg('temporary/'.$s3->bucket.$backupDirectory.'/'),
|
||||
], $server, timeout: $this->timeout, disableMultiplexing: true);
|
||||
} finally {
|
||||
|
||||
@@ -302,7 +302,7 @@ class Index extends Component
|
||||
|
||||
$this->validate();
|
||||
|
||||
$foundServer = Server::whereIp($this->remoteServerHost)->first();
|
||||
$foundServer = Server::whereIp(Server::normalizeIp($this->remoteServerHost))->first();
|
||||
if ($foundServer) {
|
||||
return $this->dispatch('error', 'A server with this IP/Domain already exists.');
|
||||
}
|
||||
|
||||
@@ -2064,7 +2064,7 @@ class Domains extends Component
|
||||
$lowerHost = strtolower($host);
|
||||
|
||||
// Always skip bare IPs and localhost.
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') {
|
||||
if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1780,7 +1780,7 @@ class Domains extends Component
|
||||
|
||||
$lowerHost = strtolower($host);
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') {
|
||||
if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
@@ -28,7 +30,6 @@ class HealthChecks extends Component
|
||||
#[Validate(['required', 'string', 'in:http,https'])]
|
||||
public string $healthCheckScheme;
|
||||
|
||||
#[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])]
|
||||
public string $healthCheckHost;
|
||||
|
||||
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
|
||||
@@ -58,23 +59,30 @@ class HealthChecks extends Component
|
||||
#[Validate(['boolean'])]
|
||||
public bool $customHealthcheckFound = false;
|
||||
|
||||
protected $rules = [
|
||||
'healthCheckEnabled' => 'boolean',
|
||||
'healthCheckType' => 'string|in:http,cmd',
|
||||
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
|
||||
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
||||
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
|
||||
'healthCheckReturnCode' => 'integer',
|
||||
'healthCheckScheme' => 'required|string|in:http,https',
|
||||
'healthCheckResponseText' => 'nullable|string',
|
||||
'healthCheckInterval' => 'integer|min:1',
|
||||
'healthCheckTimeout' => 'integer|min:1',
|
||||
'healthCheckRetries' => 'integer|min:1',
|
||||
'healthCheckStartPeriod' => 'integer',
|
||||
'customHealthcheckFound' => 'boolean',
|
||||
];
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'healthCheckEnabled' => 'boolean',
|
||||
'healthCheckType' => 'string|in:http,cmd',
|
||||
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
|
||||
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
||||
'healthCheckHost' => ['required', 'string', function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if (! ValidationPatterns::isValidHealthCheckHost($value)) {
|
||||
$fail('The health check host must be a hostname, an IPv4 address, or an IPv6 address.');
|
||||
}
|
||||
}],
|
||||
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
|
||||
'healthCheckReturnCode' => 'integer',
|
||||
'healthCheckScheme' => 'required|string|in:http,https',
|
||||
'healthCheckResponseText' => 'nullable|string',
|
||||
'healthCheckInterval' => 'integer|min:1',
|
||||
'healthCheckTimeout' => 'integer|min:1',
|
||||
'healthCheckRetries' => 'integer|min:1',
|
||||
'healthCheckStartPeriod' => 'integer',
|
||||
'customHealthcheckFound' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
|
||||
@@ -30,6 +30,8 @@ class IntegrationTokenEditor extends Component
|
||||
|
||||
public bool $automaticDns = true;
|
||||
|
||||
public bool $proxiedDns = false;
|
||||
|
||||
public function mount(string $integration_token_uuid): void
|
||||
{
|
||||
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
|
||||
@@ -43,6 +45,7 @@ class IntegrationTokenEditor extends Component
|
||||
$this->metadata = $this->integrationToken->metadata ?? [];
|
||||
$this->loadZones();
|
||||
$this->automaticDns = $this->integrationToken->automaticDnsEnabled();
|
||||
$this->proxiedDns = $this->integrationToken->proxiedDnsEnabled();
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
@@ -55,6 +58,7 @@ class IntegrationTokenEditor extends Component
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
'capabilities.*' => ['required', 'in:'.$allowedCapability],
|
||||
'automaticDns' => ['boolean'],
|
||||
'proxiedDns' => ['boolean'],
|
||||
];
|
||||
|
||||
if ($this->integrationToken->provider === 'infisical') {
|
||||
@@ -91,6 +95,11 @@ class IntegrationTokenEditor extends Component
|
||||
} else {
|
||||
$metadata['automatic_dns'] = false;
|
||||
}
|
||||
if ($validated['proxiedDns']) {
|
||||
$metadata['proxied_dns'] = true;
|
||||
} else {
|
||||
unset($metadata['proxied_dns']);
|
||||
}
|
||||
}
|
||||
$storedMetadata = $this->integrationToken->metadata ?? [];
|
||||
$changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata);
|
||||
|
||||
@@ -27,6 +27,8 @@ class IntegrationTokenForm extends Component
|
||||
|
||||
public bool $automaticDns = true;
|
||||
|
||||
public bool $proxiedDns = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('create', IntegrationToken::class);
|
||||
@@ -38,6 +40,7 @@ class IntegrationTokenForm extends Component
|
||||
$this->capabilities = ['dns'];
|
||||
$this->metadata = [];
|
||||
$this->automaticDns = true;
|
||||
$this->proxiedDns = false;
|
||||
} else {
|
||||
$this->capabilities = ['secrets'];
|
||||
$this->metadata = $this->provider === 'infisical'
|
||||
@@ -57,6 +60,7 @@ class IntegrationTokenForm extends Component
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
'capabilities.*' => ['required', 'in:'.$allowedCapability],
|
||||
'automaticDns' => ['boolean'],
|
||||
'proxiedDns' => ['boolean'],
|
||||
];
|
||||
|
||||
if ($this->provider === 'infisical') {
|
||||
@@ -94,6 +98,9 @@ class IntegrationTokenForm extends Component
|
||||
if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) {
|
||||
$metadata['automatic_dns'] = false;
|
||||
}
|
||||
if ($validated['provider'] === 'cloudflare' && $validated['proxiedDns']) {
|
||||
$metadata['proxied_dns'] = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
|
||||
|
||||
@@ -721,7 +721,7 @@ class ByHetzner extends Component
|
||||
if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
|
||||
} elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
|
||||
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||
$ipAddress = hetznerServerIpv6($hetznerServer['public_net']['ipv6']['ip']);
|
||||
}
|
||||
|
||||
// Create server in Coolify database immediately so the Hetzner
|
||||
|
||||
@@ -143,6 +143,7 @@ class ByIp extends Component
|
||||
$this->validate();
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
$this->ip = Server::normalizeIp($this->ip);
|
||||
$foundServer = Server::whereIp($this->ip)->first();
|
||||
if ($foundServer) {
|
||||
return $this->dispatch('error', 'A server with this IP/Domain already exists.');
|
||||
|
||||
@@ -250,9 +250,12 @@ class Show extends Component
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$foundServer = Server::where('ip', $this->ip)
|
||||
$this->ip = Server::normalizeIp($this->ip);
|
||||
// Check only a changed IP, so that an old duplicate does not block saving other fields.
|
||||
$ipChanged = $this->ip !== Server::normalizeIp($this->server->getRawOriginal('ip'));
|
||||
$foundServer = $ipChanged ? Server::where('ip', $this->ip)
|
||||
->where('id', '!=', $this->server->id)
|
||||
->first();
|
||||
->first() : null;
|
||||
if ($foundServer) {
|
||||
$this->ip = $this->server->ip;
|
||||
throw new \Exception('A server with this IP/Domain already exists.');
|
||||
@@ -605,7 +608,7 @@ class Show extends Component
|
||||
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
|
||||
}
|
||||
|
||||
$assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip');
|
||||
$assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? hetznerServerIpv6(data_get($serverData, 'public_net.ipv6.ip'));
|
||||
if ($this->server->backfillPlaceholderIp($assignedIp)) {
|
||||
$this->ip = $this->server->ip;
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ class Change extends Component
|
||||
$this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
|
||||
}
|
||||
if ($settings->public_ipv6) {
|
||||
$this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port');
|
||||
$this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port');
|
||||
}
|
||||
if ($this->github_app->installation_id && session('from')) {
|
||||
$source_id = data_get(session('from'), 'source_id');
|
||||
|
||||
@@ -156,7 +156,7 @@ class Change extends Component
|
||||
$this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
|
||||
}
|
||||
if ($settings->public_ipv6) {
|
||||
$this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port');
|
||||
$this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port');
|
||||
}
|
||||
|
||||
$this->parameters = get_route_parameters();
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Actions\Server\UpdateCoolify;
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Services\CoolifyUpgradeDiskSpace;
|
||||
use App\Services\CoolifyUpgradeStatus;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -62,19 +64,30 @@ class Upgrade extends Component
|
||||
$this->isUpgradeAvailable = $hasNewerVersion && $newVersionAvailable;
|
||||
}
|
||||
|
||||
public function upgrade()
|
||||
/**
|
||||
* Start the upgrade. Without $skipDiskSpaceCheck, the upgrade does not start when the disk is almost full.
|
||||
*
|
||||
* @return array{status: 'started'|'low_disk_space', available_gb?: float|null, required_gb?: int}|null
|
||||
*/
|
||||
public function upgrade(bool $skipDiskSpaceCheck = false)
|
||||
{
|
||||
try {
|
||||
if (! isInstanceAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
if ($this->updateInProgress) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
if (! $skipDiskSpaceCheck) {
|
||||
$diskSpace = $this->checkDiskSpace();
|
||||
if ($diskSpace['status'] === 'low_disk_space') {
|
||||
return $diskSpace;
|
||||
}
|
||||
}
|
||||
$this->updateInProgress = true;
|
||||
dispatch(function () {
|
||||
dispatch(function () use ($skipDiskSpaceCheck) {
|
||||
try {
|
||||
UpdateCoolify::run(manual_update: true);
|
||||
UpdateCoolify::run(manual_update: true, skipDiskSpaceCheck: $skipDiskSpaceCheck);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
@@ -85,7 +98,50 @@ class Upgrade extends Component
|
||||
'resource' => 'instance',
|
||||
'from_version' => config('constants.coolify.version'),
|
||||
'to_version' => get_latest_version_of_coolify(),
|
||||
'skip_disk_space_check' => $skipDiskSpaceCheck,
|
||||
]);
|
||||
|
||||
return ['status' => 'started'];
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: 'low_disk_space'|'ok', available_gb: float|null, required_gb: int}
|
||||
*/
|
||||
public function checkDiskSpace(): array
|
||||
{
|
||||
if (! isInstanceAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$server = Server::find(0);
|
||||
$availableGb = $server ? app(CoolifyUpgradeDiskSpace::class)->availableGb($server) : null;
|
||||
|
||||
return [
|
||||
'status' => CoolifyUpgradeDiskSpace::isLow($availableGb) ? 'low_disk_space' : 'ok',
|
||||
'available_gb' => $availableGb,
|
||||
'required_gb' => CoolifyUpgradeDiskSpace::REQUIRED_GB,
|
||||
];
|
||||
}
|
||||
|
||||
public function runDockerCleanup()
|
||||
{
|
||||
try {
|
||||
if (! isInstanceAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
$server = Server::findOrFail(0);
|
||||
DockerCleanupJob::dispatch($server, true);
|
||||
auditLog('ui.server.docker_cleanup_started', [
|
||||
'team_id' => $server->team_id,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'delete_unused_volumes' => false,
|
||||
'delete_unused_networks' => false,
|
||||
]);
|
||||
$this->dispatch('success', 'Docker cleanup started. When it is done, click "Check again".');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -62,11 +62,7 @@ class GetServerDomains extends Tool
|
||||
$domains = collect();
|
||||
|
||||
foreach ($applications as $application) {
|
||||
$fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) {
|
||||
$f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/');
|
||||
|
||||
return str(str($f[0])->explode(':')[0]);
|
||||
})->filter(fn (Stringable $f) => $f->isNotEmpty());
|
||||
$fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(fn (Stringable $f) => $f->isNotEmpty());
|
||||
|
||||
if ($fqdn->isNotEmpty()) {
|
||||
$domains->push([
|
||||
@@ -90,4 +86,17 @@ class GetServerDomains extends Tool
|
||||
'uuid' => $schema->string()->description('Server UUID.')->required(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com".
|
||||
*/
|
||||
private static function domainHostWithoutPort(string $fqdn): Stringable
|
||||
{
|
||||
$authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0];
|
||||
if (str_starts_with($authority, '[') && str_contains($authority, ']')) {
|
||||
return str(substr($authority, 0, strpos($authority, ']') + 1));
|
||||
}
|
||||
|
||||
return str(str($authority)->explode(':')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ class IntegrationToken extends BaseModel
|
||||
return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false;
|
||||
}
|
||||
|
||||
/** New Cloudflare records are created proxied (orange cloud) when enabled, so the server IP is not exposed. */
|
||||
public function proxiedDnsEnabled(): bool
|
||||
{
|
||||
return $this->provider === 'cloudflare' && data_get($this->metadata, 'proxied_dns') === true;
|
||||
}
|
||||
|
||||
public function dopplerTokenType(): ?string
|
||||
{
|
||||
if ($this->provider !== 'doppler') {
|
||||
|
||||
+21
-5
@@ -401,6 +401,7 @@ class Server extends BaseModel
|
||||
*/
|
||||
public function backfillPlaceholderIp(?string $ip): bool
|
||||
{
|
||||
$ip = blank($ip) ? null : self::normalizeIp($ip);
|
||||
if (self::isPlaceholderIp($ip)) {
|
||||
return false;
|
||||
}
|
||||
@@ -451,7 +452,7 @@ class Server extends BaseModel
|
||||
$hetznerService = new HetznerService($this->cloudProviderToken->token);
|
||||
$server = $hetznerService->getServer($this->hetzner_server_id);
|
||||
$status = $server['status'] ?? null;
|
||||
$assignedIp = data_get($server, 'public_net.ipv4.ip') ?? data_get($server, 'public_net.ipv6.ip');
|
||||
$assignedIp = data_get($server, 'public_net.ipv4.ip') ?? hetznerServerIpv6(data_get($server, 'public_net.ipv6.ip'));
|
||||
|
||||
$updates = [];
|
||||
if ($this->hetzner_server_status !== $status) {
|
||||
@@ -1054,7 +1055,7 @@ class Server extends BaseModel
|
||||
], $this);
|
||||
} else {
|
||||
$url = Url::fromString($settings->fqdn);
|
||||
$host = $url->getHost();
|
||||
$host = $this->dashboardTraefikHost($url->getHost());
|
||||
$schema = $url->getScheme();
|
||||
$traefik_dynamic_conf = [
|
||||
'http' => [
|
||||
@@ -1243,6 +1244,14 @@ $siteAddress {
|
||||
return ['gzip'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Traefik matches an IPv6 Host() rule only without brackets (Host(`2a01:4f8::1`)).
|
||||
*/
|
||||
public function dashboardTraefikHost(string $host): string
|
||||
{
|
||||
return trim($host, '[]');
|
||||
}
|
||||
|
||||
public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string
|
||||
{
|
||||
if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) {
|
||||
@@ -1858,12 +1867,19 @@ $siteAddress {
|
||||
get: function ($value) {
|
||||
return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value);
|
||||
},
|
||||
set: function ($value) {
|
||||
return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value);
|
||||
}
|
||||
set: fn ($value) => self::normalizeIp($value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The form a server IP is stored in: unsafe characters removed (including IPv6 brackets) and
|
||||
* IPv6 addresses in their short lower-case form (2A01:04F8::0001 -> 2a01:4f8::1).
|
||||
*/
|
||||
public static function normalizeIp(?string $value): string
|
||||
{
|
||||
return normalizeIpAddress(preg_replace('/[^0-9a-zA-Z.:%-]/', '', (string) $value));
|
||||
}
|
||||
|
||||
public function getIp(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
||||
@@ -330,7 +330,7 @@ class ServerSetting extends Model
|
||||
return match (true) {
|
||||
filled($fqdn) => $fqdn,
|
||||
filled($publicIpv4) => 'http://'.$publicIpv4.':8000',
|
||||
filled($publicIpv6) => 'http://'.$publicIpv6.':8000',
|
||||
filled($publicIpv6) => 'http://'.formatHostForUrl($publicIpv6).':8000',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
@@ -392,7 +392,7 @@ class ServerSetting extends Model
|
||||
}
|
||||
|
||||
$request = request();
|
||||
$host = strtolower($request->getHost());
|
||||
$host = normalizeIpAddress(strtolower($request->getHost()));
|
||||
|
||||
if (
|
||||
$host === 'localhost' ||
|
||||
|
||||
@@ -151,7 +151,7 @@ class ServiceDatabase extends BaseModel
|
||||
$realIp = base_ip();
|
||||
}
|
||||
|
||||
return "{$realIp}:{$port}";
|
||||
return formatHostForUrl((string) $realIp).":{$port}";
|
||||
}
|
||||
|
||||
public function team()
|
||||
|
||||
@@ -379,11 +379,12 @@ class StandaloneClickhouse extends BaseModel
|
||||
if (empty($serverIp)) {
|
||||
return null;
|
||||
}
|
||||
$serverHost = formatHostForUrl($serverIp);
|
||||
$encodedUser = rawurlencode($this->clickhouse_admin_user);
|
||||
$encodedPass = rawurlencode($this->clickhouse_admin_password);
|
||||
$database = $this->clickhouse_db ?? 'default';
|
||||
|
||||
return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$database}";
|
||||
return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$database}";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -333,9 +333,10 @@ class StandaloneDragonfly extends BaseModel
|
||||
if (empty($serverIp)) {
|
||||
return null;
|
||||
}
|
||||
$serverHost = formatHostForUrl($serverIp);
|
||||
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
|
||||
$encodedPass = rawurlencode($this->connectionPassword());
|
||||
$url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0";
|
||||
$url = "{$scheme}://:{$encodedPass}@{$serverHost}:{$this->public_port}/0";
|
||||
|
||||
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
|
||||
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
|
||||
|
||||
@@ -334,9 +334,10 @@ class StandaloneKeydb extends BaseModel
|
||||
if (empty($serverIp)) {
|
||||
return null;
|
||||
}
|
||||
$serverHost = formatHostForUrl($serverIp);
|
||||
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
|
||||
$encodedPass = rawurlencode($this->connectionPassword());
|
||||
$url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0";
|
||||
$url = "{$scheme}://:{$encodedPass}@{$serverHost}:{$this->public_port}/0";
|
||||
|
||||
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
|
||||
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
|
||||
|
||||
@@ -321,10 +321,11 @@ class StandaloneMariadb extends BaseModel
|
||||
if (empty($serverIp)) {
|
||||
return null;
|
||||
}
|
||||
$serverHost = formatHostForUrl($serverIp);
|
||||
$encodedUser = rawurlencode($this->mariadb_user);
|
||||
$encodedPass = rawurlencode($this->mariadb_password);
|
||||
|
||||
return "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mariadb_database}";
|
||||
return "mysql://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->mariadb_database}";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -354,9 +354,10 @@ class StandaloneMongodb extends BaseModel
|
||||
if (empty($serverIp)) {
|
||||
return null;
|
||||
}
|
||||
$serverHost = formatHostForUrl($serverIp);
|
||||
$encodedUser = rawurlencode($this->mongo_initdb_root_username);
|
||||
$encodedPass = rawurlencode($this->mongo_initdb_root_password);
|
||||
$url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/?directConnection=true";
|
||||
$url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/?directConnection=true";
|
||||
if ($this->enable_ssl) {
|
||||
$url .= '&tls=true&tlsCAFile=/etc/mongo/certs/ca.pem';
|
||||
if (in_array($this->ssl_mode, ['verify-full'])) {
|
||||
|
||||
@@ -335,9 +335,10 @@ class StandaloneMysql extends BaseModel
|
||||
if (empty($serverIp)) {
|
||||
return null;
|
||||
}
|
||||
$serverHost = formatHostForUrl($serverIp);
|
||||
$encodedUser = rawurlencode($this->mysql_user);
|
||||
$encodedPass = rawurlencode($this->mysql_password);
|
||||
$url = "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mysql_database}";
|
||||
$url = "mysql://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->mysql_database}";
|
||||
if ($this->enable_ssl) {
|
||||
$url .= "?ssl-mode={$this->ssl_mode}";
|
||||
if (in_array($this->ssl_mode, ['VERIFY_CA', 'VERIFY_IDENTITY'])) {
|
||||
|
||||
@@ -346,9 +346,10 @@ class StandalonePostgresql extends BaseModel
|
||||
if (empty($serverIp)) {
|
||||
return null;
|
||||
}
|
||||
$serverHost = formatHostForUrl($serverIp);
|
||||
$encodedUser = rawurlencode($this->postgres_user);
|
||||
$encodedPass = rawurlencode($this->postgres_password);
|
||||
$url = "postgres://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->postgres_db}";
|
||||
$url = "postgres://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->postgres_db}";
|
||||
if ($this->enable_ssl) {
|
||||
$url .= "?sslmode={$this->ssl_mode}";
|
||||
if (in_array($this->ssl_mode, ['verify-ca', 'verify-full'])) {
|
||||
|
||||
@@ -105,12 +105,6 @@ class StandaloneRedis extends BaseModel
|
||||
$database->last_online_at = now();
|
||||
}
|
||||
});
|
||||
|
||||
static::retrieved(function ($database) {
|
||||
if (! $database->redis_username) {
|
||||
$database->redis_username = 'default';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,11 +333,12 @@ class StandaloneRedis extends BaseModel
|
||||
if (empty($serverIp)) {
|
||||
return null;
|
||||
}
|
||||
$serverHost = formatHostForUrl($serverIp);
|
||||
$redis_version = $this->getRedisVersion();
|
||||
$username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : '';
|
||||
$encodedPass = rawurlencode($this->connectionPassword());
|
||||
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
|
||||
$url = "{$scheme}://{$username_part}{$encodedPass}@{$serverIp}:{$this->public_port}/0";
|
||||
$url = "{$scheme}://{$username_part}{$encodedPass}@{$serverHost}:{$this->public_port}/0";
|
||||
|
||||
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
|
||||
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
|
||||
@@ -472,19 +467,10 @@ class StandaloneRedis extends BaseModel
|
||||
{
|
||||
return new Attribute(
|
||||
get: function () {
|
||||
// A loaded relation does not hold a variable created by an earlier read, so check the table before creating one.
|
||||
$username = $this->runtimeEnvironmentVariable('REDIS_USERNAME')
|
||||
?? $this->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->first();
|
||||
if (! $username) {
|
||||
$this->runtime_environment_variables()->create([
|
||||
'key' => 'REDIS_USERNAME',
|
||||
'value' => 'default',
|
||||
]);
|
||||
// Reading must not create the variable: a read during the create or clone of the variables adds a duplicate.
|
||||
$username = $this->runtimeEnvironmentVariable('REDIS_USERNAME')?->value;
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
return $username->value;
|
||||
return filled($username) ? $username : 'default';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Server;
|
||||
|
||||
use App\Contracts\ThrottledNotification;
|
||||
use App\Models\Server;
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use App\Services\CoolifyUpgradeDiskSpace;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class UpgradeSkippedLowDiskSpace extends CustomEmailNotification implements ThrottledNotification
|
||||
{
|
||||
public int $requiredGb = CoolifyUpgradeDiskSpace::REQUIRED_GB;
|
||||
|
||||
public function __construct(public Server $server, public string $version, public float $availableGb)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable->getEnabledChannels('server_disk_usage');
|
||||
}
|
||||
|
||||
public function throttleSubject(): ?Model
|
||||
{
|
||||
return $this->server;
|
||||
}
|
||||
|
||||
public function throttleIntervalMinutes(): int
|
||||
{
|
||||
return 24 * 60;
|
||||
}
|
||||
|
||||
private function cleanupUrl(): string
|
||||
{
|
||||
return base_url().'/server/'.$this->server->uuid.'/docker-cleanup';
|
||||
}
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$mail->subject("Coolify: Automatic update to {$this->version} skipped, not enough free disk space");
|
||||
$mail->view('emails.upgrade-skipped-low-disk-space', [
|
||||
'version' => $this->version,
|
||||
'available_gb' => $this->availableGb,
|
||||
'required_gb' => $this->requiredGb,
|
||||
'cleanup_url' => $this->cleanupUrl(),
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public function toDiscord(): DiscordMessage
|
||||
{
|
||||
$message = new DiscordMessage(
|
||||
title: ':warning: Automatic update skipped',
|
||||
description: "Coolify did not update to {$this->version} because the server does not have enough free disk space.",
|
||||
color: DiscordMessage::warningColor(),
|
||||
isCritical: true,
|
||||
);
|
||||
|
||||
$message->addField('Free', "{$this->availableGb} GB", true);
|
||||
$message->addField('Required', "{$this->requiredGb} GB", true);
|
||||
$message->addField('What to do?', '[Docker cleanup]('.$this->cleanupUrl().')', true);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function toTelegram(): array
|
||||
{
|
||||
return [
|
||||
'message' => "Coolify: Automatic update to {$this->version} skipped.\nFree disk space: {$this->availableGb} GB. Required: {$this->requiredGb} GB.\nFree up disk space (for example, run a Docker cleanup): {$this->cleanupUrl()}",
|
||||
];
|
||||
}
|
||||
|
||||
public function toPushover(): PushoverMessage
|
||||
{
|
||||
return new PushoverMessage(
|
||||
title: 'Automatic update skipped',
|
||||
level: 'warning',
|
||||
message: "Coolify did not update to {$this->version} because the server does not have enough free disk space.<br/><br/><b>Free:</b> {$this->availableGb} GB.<br/><b>Required:</b> {$this->requiredGb} GB.",
|
||||
buttons: [
|
||||
'Docker cleanup' => $this->cleanupUrl(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function toSlack(): SlackMessage
|
||||
{
|
||||
$description = "Coolify did not update to {$this->version} because the server does not have enough free disk space.\n";
|
||||
$description .= "Free: {$this->availableGb} GB\n";
|
||||
$description .= "Required: {$this->requiredGb} GB\n\n";
|
||||
$description .= 'Free up disk space (for example, run a Docker cleanup): '.$this->cleanupUrl();
|
||||
|
||||
return new SlackMessage(
|
||||
title: 'Automatic update skipped',
|
||||
description: $description,
|
||||
color: SlackMessage::warningColor()
|
||||
);
|
||||
}
|
||||
|
||||
public function toWebhook(): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Automatic update skipped, not enough free disk space',
|
||||
'event' => 'upgrade_skipped_low_disk_space',
|
||||
'version' => $this->version,
|
||||
'server_name' => $this->server->name,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'available_gb' => $this->availableGb,
|
||||
'required_gb' => $this->requiredGb,
|
||||
'url' => $this->cleanupUrl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ use App\Auth\Oidc\Socialite\OidcProvider;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\Server;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use App\Support\AtomicFilesystem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Console\ConfigCacheCommand;
|
||||
use Illuminate\Foundation\Console\RouteCacheCommand;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -25,6 +28,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
|
||||
|
||||
// Requests must never read a partly written cache file. See AtomicFilesystem.
|
||||
$this->app->extend(ConfigCacheCommand::class, fn () => new ConfigCacheCommand(new AtomicFilesystem));
|
||||
$this->app->extend(RouteCacheCommand::class, fn () => new RouteCacheCommand(new AtomicFilesystem));
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
@@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Log;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Throwable;
|
||||
|
||||
@@ -362,10 +361,9 @@ class SafeWebhookUrl implements ValidationRule
|
||||
foreach ($dnsServers as $dnsServer) {
|
||||
foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) {
|
||||
try {
|
||||
$query = new DNSQuery($dnsServer, 53, 5);
|
||||
$records = $query->query($host, $type);
|
||||
$records = queryDnsServer($dnsServer, $host, $type);
|
||||
|
||||
if ($records === false || $query->hasError()) {
|
||||
if ($records === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -395,7 +393,7 @@ class SafeWebhookUrl implements ValidationRule
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
fn (string $server): string => trim($server),
|
||||
fn (string $server): string => normalizeIpAddress($server),
|
||||
explode(',', $servers),
|
||||
), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
|
||||
protected bool $allowIP;
|
||||
|
||||
public function __construct(bool $allowSSH = true, bool $allowIP = false)
|
||||
public function __construct(bool $allowSSH = true, bool $allowIP = true)
|
||||
{
|
||||
$this->allowSSH = $allowSSH;
|
||||
$this->allowIP = $allowIP;
|
||||
@@ -27,6 +27,13 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
return;
|
||||
}
|
||||
|
||||
// Brackets are only allowed around an IPv6 host, so check the rest of the URL without it.
|
||||
$valueWithoutIpv6Host = preg_replace_callback(
|
||||
'/^((?:https?|git):\/\/|[a-zA-Z0-9._-]+@)\[([0-9A-Fa-f:.]+)\]/',
|
||||
fn (array $matches): string => filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? $matches[1].'ipv6-host' : $matches[0],
|
||||
$value,
|
||||
);
|
||||
|
||||
// Check for dangerous shell metacharacters that could be used for command injection
|
||||
$dangerousChars = [
|
||||
';', '|', '&', '$', '`', '(', ')', '{', '}',
|
||||
@@ -36,7 +43,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
];
|
||||
|
||||
foreach ($dangerousChars as $char) {
|
||||
if (str_contains($value, $char)) {
|
||||
if (str_contains($valueWithoutIpv6Host, $char)) {
|
||||
Log::warning('Git repository URL validation failed - dangerous character', [
|
||||
'url' => $value,
|
||||
'character' => $char,
|
||||
@@ -63,7 +70,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
];
|
||||
|
||||
foreach ($dangerousPatterns as $pattern) {
|
||||
if (preg_match($pattern, $value)) {
|
||||
if (preg_match($pattern, $valueWithoutIpv6Host)) {
|
||||
Log::warning('Git repository URL validation failed - dangerous pattern', [
|
||||
'url' => $value,
|
||||
'pattern' => $pattern,
|
||||
@@ -77,7 +84,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
}
|
||||
|
||||
// Validate based on URL type
|
||||
if (preg_match('/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:/', $value)) {
|
||||
if (preg_match('/^[a-zA-Z0-9._-]+@(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+):/', $value)) {
|
||||
if (! $this->allowSSH) {
|
||||
$fail('SSH URLs are not allowed.');
|
||||
|
||||
@@ -86,7 +93,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
|
||||
// Validate scp-style SSH URL format (user@host:user/repo.git)
|
||||
$scp = parseScpStyleGitUrl($value);
|
||||
if ($scp === null || preg_match('/^[a-zA-Z0-9.-]+$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) {
|
||||
if ($scp === null || preg_match('/^(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) {
|
||||
$fail('The :attribute is not a valid SSH repository URL.');
|
||||
|
||||
return;
|
||||
@@ -100,9 +107,11 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
}
|
||||
|
||||
$parsed = parse_url($value);
|
||||
// parse_url() keeps the brackets around an IPv6 host.
|
||||
$host = trim(strtolower($parsed['host'] ?? ''), '[]');
|
||||
|
||||
// Check for IP addresses if not allowed
|
||||
if (! $this->allowIP && filter_var($parsed['host'] ?? '', FILTER_VALIDATE_IP)) {
|
||||
if (! $this->allowIP && filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
Log::warning('Git repository URL contains IP address', [
|
||||
'url' => $value,
|
||||
'ip' => request()->ip(),
|
||||
@@ -114,9 +123,10 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
}
|
||||
|
||||
// Check for localhost/internal addresses
|
||||
$host = strtolower($parsed['host'] ?? '');
|
||||
$internalHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1'];
|
||||
if (in_array($host, $internalHosts) || str_ends_with($host, '.local')) {
|
||||
$isReservedIp = filter_var($host, FILTER_VALIDATE_IP) !== false
|
||||
&& filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) === false;
|
||||
if (in_array($host, $internalHosts) || $isReservedIp || str_ends_with($host, '.local')) {
|
||||
Log::warning('Git repository URL points to internal host', [
|
||||
'url' => $value,
|
||||
'host' => $host,
|
||||
@@ -144,7 +154,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
}
|
||||
} elseif (str_starts_with($value, 'git://')) {
|
||||
// Validate git:// protocol URL (supports both git://host/path and git://host:port/path with tilde)
|
||||
if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) {
|
||||
if (! preg_match('/^git:\/\/(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9\.\-]+)(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) {
|
||||
$fail('The :attribute is not a valid git:// URL.');
|
||||
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
|
||||
class ValidServerIp implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Accepts a valid IPv4 address, IPv6 address, or RFC 1123 hostname.
|
||||
* Accepts a valid IPv4 address, IPv6 address (optionally in brackets), or RFC 1123 hostname.
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
@@ -26,6 +26,12 @@ class ValidServerIp implements ValidationRule
|
||||
return;
|
||||
}
|
||||
|
||||
// A bracketed IPv6 address ([2a01:4f8::1]); it is stored without brackets.
|
||||
if (str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']')
|
||||
&& filter_var(substr($trimmed, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate hostname validation to ValidHostname
|
||||
$hostnameRule = new ValidHostname;
|
||||
$failed = false;
|
||||
|
||||
@@ -37,7 +37,7 @@ class ConfigurationRepository
|
||||
$this->config->set('mail.mailers.smtp', [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => $mailerOptions['scheme'],
|
||||
'host' => $settings->smtp_host,
|
||||
'host' => filled($settings->smtp_host) ? formatHostForUrl($settings->smtp_host) : $settings->smtp_host,
|
||||
'port' => $settings->smtp_port,
|
||||
'encryption' => $mailerOptions['encryption'],
|
||||
'username' => $settings->smtp_username,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
/**
|
||||
* An upgrade pulls new images and recreates the Coolify containers. When the disk becomes full
|
||||
* during an upgrade, Coolify can stop working (for example, an empty config cache file).
|
||||
*/
|
||||
class CoolifyUpgradeDiskSpace
|
||||
{
|
||||
public const REQUIRED_GB = 5;
|
||||
|
||||
public const UPGRADE_PATHS = ['/data/coolify'];
|
||||
|
||||
public const DEFAULT_DOCKER_ROOT_DIR = '/var/lib/docker';
|
||||
|
||||
/**
|
||||
* Free space in GB on the Docker data directory and /data/coolify. The smaller value is returned.
|
||||
* Returns null when the free space cannot be measured, so an unknown value never blocks an upgrade.
|
||||
*/
|
||||
public function availableGb(Server $server): ?float
|
||||
{
|
||||
$dockerRootDir = trim((string) instant_remote_process(
|
||||
["docker info --format '{{.DockerRootDir}}' 2>/dev/null || true"],
|
||||
$server,
|
||||
false
|
||||
));
|
||||
|
||||
$paths = collect([...self::UPGRADE_PATHS, $dockerRootDir ?: self::DEFAULT_DOCKER_ROOT_DIR])
|
||||
->map(fn (string $path) => escapeshellarg($path))
|
||||
->implode(' ');
|
||||
|
||||
$output = instant_remote_process(["df -Pk {$paths} 2>/dev/null || true"], $server, false);
|
||||
|
||||
return self::parseDfOutput((string) $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the smallest "Available" column (in 1K blocks) from `df -Pk` output and return it in GB.
|
||||
*/
|
||||
public static function parseDfOutput(string $output): ?float
|
||||
{
|
||||
$availableKb = collect(preg_split('/\R/', trim($output)))
|
||||
->skip(1)
|
||||
->map(fn (string $line) => preg_split('/\s+/', trim($line))[3] ?? null)
|
||||
->filter(fn (?string $value) => $value !== null && ctype_digit($value))
|
||||
->map(fn (string $value) => (int) $value)
|
||||
->min();
|
||||
|
||||
if ($availableKb === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round($availableKb / 1024 / 1024, 1);
|
||||
}
|
||||
|
||||
public static function isLow(?float $availableGb): bool
|
||||
{
|
||||
return $availableGb !== null && $availableGb < self::REQUIRED_GB;
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,8 @@ class CloudflareDnsProvider
|
||||
|
||||
$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,
|
||||
'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1,
|
||||
'proxied' => $zone->integrationToken->proxiedDnsEnabled(),
|
||||
'comment' => ManagedDnsRecord::ownershipCommentFor($uuid),
|
||||
]);
|
||||
if (! $response->successful() || ! is_string($response->json('result.id'))) {
|
||||
|
||||
@@ -15,7 +15,11 @@ class DockerRegistryLogins
|
||||
{
|
||||
public const DOCKER_HUB = 'docker.io';
|
||||
|
||||
public const REGISTRY_PATTERN = '/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$/';
|
||||
/**
|
||||
* A registry host with an optional port. An IPv6 host is bracketed ([2a01:4f8::1]:5000) and holds only
|
||||
* hex digits, ':' and '.', so the value stays safe in shell commands.
|
||||
*/
|
||||
public const REGISTRY_PATTERN = '/^(?:[a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(:[0-9]{1,5})?$/';
|
||||
|
||||
/**
|
||||
* Read the registries a server is logged in to from the Docker config that deployments mount.
|
||||
|
||||
@@ -179,18 +179,19 @@ class HetznerService
|
||||
|
||||
public function findServerByIp(string $ip): ?array
|
||||
{
|
||||
$ip = normalizeIpAddress($ip);
|
||||
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
$servers = $this->getServers();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
// Check IPv4
|
||||
$ipv4 = data_get($server, 'public_net.ipv4.ip');
|
||||
if ($ipv4 === $ip) {
|
||||
if (filled($ipv4) && normalizeIpAddress($ipv4) === $ip) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
// Check IPv6 (Hetzner returns the full /64 block)
|
||||
$ipv6 = data_get($server, 'public_net.ipv6.ip');
|
||||
if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) {
|
||||
// Hetzner returns the IPv6 network (2a01:4f8:c016:bd23::/64), not the server address.
|
||||
$ipv6Network = data_get($server, 'public_net.ipv6.ip');
|
||||
if ($isIpv6 && filled($ipv6Network) && str_contains($ipv6Network, '/') && ipMatch($ip, [$ipv6Network])) {
|
||||
return $server;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,14 @@ class InfisicalService
|
||||
{
|
||||
$client = $this->client()->withToken($this->login());
|
||||
$secretPath = $secretPath ?: '/';
|
||||
// Older Infisical versions do not expand ${KEY} references unless asked to.
|
||||
$valueOptions = ['expandSecretReferences' => 'true', 'viewSecretValue' => 'true'];
|
||||
|
||||
$response = $client->get($this->baseUrl.'/api/v4/secrets', [
|
||||
'projectId' => $projectId,
|
||||
'environment' => $environment,
|
||||
'secretPath' => $secretPath,
|
||||
...$valueOptions,
|
||||
]);
|
||||
|
||||
// Older self-hosted instances only expose the v3 endpoint.
|
||||
@@ -52,6 +55,7 @@ class InfisicalService
|
||||
'workspaceId' => $projectId,
|
||||
'environment' => $environment,
|
||||
'secretPath' => $secretPath,
|
||||
...$valueOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ class ServerTransferImporter
|
||||
$serverIp = (string) data_get($bundle, 'server.ip');
|
||||
|
||||
$uuidConflict = Server::withTrashed()->where('uuid', $serverUuid)->exists() && $preserveUuids;
|
||||
$existingIp = Server::where('ip', $serverIp)->first();
|
||||
$existingIp = Server::where('ip', Server::normalizeIp($serverIp))->first();
|
||||
$ipConflict = $existingIp !== null;
|
||||
|
||||
if (! $dryRun && $uuidConflict) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Laravel writes the config and route cache files in place: the file is empty until the write ends.
|
||||
* PHP-FPM already serves requests while app:init caches them, and OPcache (validate_timestamps=0)
|
||||
* keeps an empty file it read, so every request fails until a restart (#12120).
|
||||
*/
|
||||
class AtomicFilesystem extends Filesystem
|
||||
{
|
||||
/**
|
||||
* Write to a temporary file in the same directory, then rename it over the target.
|
||||
* Readers see the old or the new complete file. When the write fails, the old file stays.
|
||||
*/
|
||||
public function put($path, $contents, $lock = false)
|
||||
{
|
||||
$directory = dirname($path);
|
||||
$tempPath = @tempnam($directory, basename($path));
|
||||
|
||||
// tempnam() falls back to the system temp directory, where rename() is not atomic.
|
||||
if ($tempPath === false || dirname($tempPath) !== realpath($directory)) {
|
||||
if ($tempPath !== false) {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
|
||||
throw new RuntimeException("Could not create a temporary file to write [{$path}].");
|
||||
}
|
||||
|
||||
$bytes = file_put_contents($tempPath, $contents);
|
||||
if ($bytes !== strlen($contents)) {
|
||||
@unlink($tempPath);
|
||||
|
||||
throw new RuntimeException("Could not write [{$path}].");
|
||||
}
|
||||
|
||||
// tempnam() creates the file with 0600. Use the permissions file_put_contents() would give.
|
||||
@chmod($tempPath, 0666 & ~umask());
|
||||
|
||||
if (! rename($tempPath, $path)) {
|
||||
@unlink($tempPath);
|
||||
|
||||
throw new RuntimeException("Could not replace [{$path}].");
|
||||
}
|
||||
|
||||
return $bytes;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,14 @@ class DomainFavicon
|
||||
|| ! DnsRecordHints::isPublicAddress($embeddedIp);
|
||||
}
|
||||
|
||||
// sslip.io writes an IPv6 address with dashes: fd00--1.sslip.io is fd00::1.
|
||||
if (preg_match('/(?:^|\.)([0-9a-f]*-[0-9a-f-]*)\.(?:sslip|nip)\.io$/', $host, $matches) === 1) {
|
||||
$embeddedIp = str_replace('-', ':', $matches[1]);
|
||||
|
||||
return filter_var($embeddedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
|
||||
&& ! DnsRecordHints::isPublicAddress($embeddedIp);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class SmtpTransportFactory
|
||||
$mode = self::encryptionMode($settings);
|
||||
|
||||
$transport = new EsmtpTransport(
|
||||
$settings->smtp_host,
|
||||
formatHostForUrl((string) $settings->smtp_host),
|
||||
(int) $settings->smtp_port,
|
||||
match ($mode) {
|
||||
'none' => false,
|
||||
|
||||
@@ -137,10 +137,11 @@ class ValidationPatterns
|
||||
* Pattern for Docker image repository names without a tag.
|
||||
*
|
||||
* Allows an optional registry host/port followed by lowercase repository
|
||||
* path components. A trailing @sha256 marker is accepted for existing
|
||||
* path components. A bracketed IPv6 registry host ([2a01:4f8::1]:5000) may
|
||||
* only hold hex digits, ':' and '.'. A trailing @sha256 marker is accepted for existing
|
||||
* digest-based dockerimage records that store the digest hash separately.
|
||||
*/
|
||||
public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/';
|
||||
public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/';
|
||||
|
||||
/**
|
||||
* Pattern for Docker image tags.
|
||||
@@ -327,6 +328,24 @@ class ValidationPatterns
|
||||
return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a safe health check host: a hostname or IPv4 address, or an IPv6 address
|
||||
* with or without brackets (::1, [::1]).
|
||||
*/
|
||||
public static function isValidHealthCheckHost(?string $value): bool
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-zA-Z0-9.\-_]+$/', $value) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/^(?:\[[0-9a-fA-F:.]+\]|[0-9a-fA-F:.]+)$/', $value) === 1
|
||||
&& filter_var(trim($value, '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid Docker image tag.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Traits;
|
||||
|
||||
use App\Services\DatabaseStartCommandExecutor;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
trait ExecutesDatabaseStartCommands
|
||||
@@ -16,4 +17,14 @@ trait ExecutesDatabaseStartCommands
|
||||
|
||||
return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged', queue: deployment_queue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `KEY=value` list already defines the exact key (not just a key or value containing it).
|
||||
*
|
||||
* @param Collection<int, string> $environmentVariables
|
||||
*/
|
||||
private function hasEnvironmentVariable(Collection $environmentVariables, string $key): bool
|
||||
{
|
||||
return $environmentVariables->contains(fn ($variable) => str($variable)->before('=')->value() === $key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +240,11 @@ function sharedDataApplications(?Application $application = null): array
|
||||
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
|
||||
'health_check_port' => 'integer|nullable|min:1|max:65535',
|
||||
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'health_check_host' => ['string', function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if (! ValidationPatterns::isValidHealthCheckHost($value)) {
|
||||
$fail('The health check host must be a hostname or an IP address.');
|
||||
}
|
||||
}],
|
||||
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
|
||||
'health_check_return_code' => 'numeric',
|
||||
'health_check_scheme' => 'string|in:http,https',
|
||||
|
||||
@@ -509,6 +509,15 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
|
||||
// Clone production environment variables without triggering the created hook
|
||||
$environmentVariables = $source->environment_variables()->get();
|
||||
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
|
||||
|
||||
// Drop defaults auto-created for the new application (e.g. NIXPACKS_NODE_VERSION) so the source values win
|
||||
EnvironmentVariable::query()
|
||||
->where('resourceable_type', $newApplication->getMorphClass())
|
||||
->where('resourceable_id', $newApplication->id)
|
||||
->whereIn('key', $environmentVariables->pluck('key')->merge($previewEnvironmentVariables->pluck('key'))->unique())
|
||||
->delete();
|
||||
|
||||
foreach ($environmentVariables as $environmentVariable) {
|
||||
EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
|
||||
$newEnvironmentVariable = $environmentVariable->replicate([
|
||||
@@ -527,7 +536,6 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
$source->cloneSecretManagerLinkTo($newApplication);
|
||||
|
||||
// Clone preview environment variables
|
||||
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
|
||||
foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
|
||||
EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
|
||||
$newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
|
||||
|
||||
@@ -235,7 +235,7 @@ function streamBackupFromServer(Server $server, string $filename, string $conten
|
||||
|
||||
$disk = Storage::build([
|
||||
'driver' => 'sftp',
|
||||
'host' => $server->ip,
|
||||
'host' => formatHostForUrl($server->ip),
|
||||
'port' => (int) $server->port,
|
||||
'username' => $server->user,
|
||||
'privateKey' => $privateKey->getKeyLocation(),
|
||||
|
||||
@@ -89,10 +89,12 @@ function containerOwnerUuid(Collection|array|string $labels, string $type): ?str
|
||||
|
||||
/**
|
||||
* Resource that owns a container, from the given candidates, by UUID label or compose project /
|
||||
* stack namespace (see containerOwnerUuid()). Applications deployed before mid-2024 used the
|
||||
* deployment directory as compose project; their compose service (the container name) starts
|
||||
* with the application UUID. The numeric id label is never used: another Coolify instance that
|
||||
* manages the same server can have a resource with the same id.
|
||||
* stack namespace (see containerOwnerUuid()). Some older application containers have another
|
||||
* compose project: the deployment directory (before mid-2024, or a custom Compose start command
|
||||
* without --project-name). Their container name (`coolify.name`, for Dockerfile-style builds also
|
||||
* the compose service) contains the application UUID as a dash-separated part, for example
|
||||
* `{uuid}-104512123456` or `{service}-{uuid}-104512123456`. The numeric id label is never used:
|
||||
* another Coolify instance that manages the same server can have a resource with the same id.
|
||||
*
|
||||
* @template TResource of \Illuminate\Database\Eloquent\Model
|
||||
*
|
||||
@@ -110,9 +112,11 @@ function resolveContainerOwner(Collection $resources, Collection|array|string $l
|
||||
return $owner;
|
||||
}
|
||||
|
||||
$composeService = (string) $labels->get('com.docker.compose.service');
|
||||
$names = [(string) $labels->get('com.docker.compose.service'), (string) $labels->get('coolify.name')];
|
||||
|
||||
return $resources->first(fn ($resource) => $composeService === $resource->uuid || str_starts_with($composeService, $resource->uuid.'-'));
|
||||
return $resources->first(fn ($resource) => collect($names)->contains(
|
||||
fn (string $name) => filled($resource->uuid) && preg_match('/(?:^|-)'.preg_quote($resource->uuid, '/').'(?:-|$)/', $name) === 1
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,8 +242,8 @@ function dockerPsByOwnerCommands(string $type, string $uuid, array $extraFilters
|
||||
$base.' '.$filters(["label=coolify.{$type}Id", "label=com.docker.compose.project={$uuid}", ...$legacyFilters]).' '.$formatArgument,
|
||||
];
|
||||
if ($type === 'application') {
|
||||
// Deployed before mid-2024 (see resolveContainerOwner()): the container name starts with the UUID.
|
||||
$commands[] = $base.' '.$filters(['label=coolify.applicationId', "name=^{$uuid}", ...$legacyFilters]).' '.$formatArgument;
|
||||
// Compose project is the deployment directory (see resolveContainerOwner()): the container name contains the UUID.
|
||||
$commands[] = $base.' '.$filters(['label=coolify.applicationId', "name={$uuid}", ...$legacyFilters]).' '.$formatArgument;
|
||||
}
|
||||
|
||||
return $commands;
|
||||
@@ -922,6 +926,19 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra
|
||||
->unique()->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Traefik matches an IPv6 host without brackets: Host(`[2a01:4f8::1]`) never matches a request,
|
||||
* Host(`2a01:4f8::1`) does. Labels that were saved with brackets get them removed here.
|
||||
*/
|
||||
function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection
|
||||
{
|
||||
return $labels->map(fn ($label) => is_string($label) ? preg_replace_callback(
|
||||
'/Host\(`\[([0-9A-Fa-f:.]+)\]`\)/',
|
||||
fn (array $matches): string => filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? "Host(`{$matches[1]}`)" : $matches[0],
|
||||
$label,
|
||||
) : $label);
|
||||
}
|
||||
|
||||
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?int $http_basic_auth_bcrypt_cost = null)
|
||||
{
|
||||
$labels = collect([]);
|
||||
@@ -975,6 +992,8 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
|
||||
$url = Url::fromString($domain);
|
||||
$host = $url->getHost();
|
||||
// Traefik matches an IPv6 Host header without brackets: Host(`[2a01:4f8::1]`) never matches.
|
||||
$traefikHost = trim($host, '[]');
|
||||
$path = $url->getPath();
|
||||
$schema = $url->getScheme();
|
||||
$portlessDomain = ServiceApplication::withoutPort($domain);
|
||||
@@ -1019,7 +1038,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
];
|
||||
if ($schema === 'https') {
|
||||
// Set labels for https
|
||||
$labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)");
|
||||
$labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)");
|
||||
$labels->push("traefik.http.routers.{$https_label}.entryPoints=https");
|
||||
if ($port) {
|
||||
$labels->push("traefik.http.routers.{$https_label}.service={$https_label}");
|
||||
@@ -1091,7 +1110,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
$labels->push("traefik.http.routers.{$https_label}.tls.certresolver=letsencrypt");
|
||||
|
||||
// Set labels for http (redirect to https)
|
||||
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)");
|
||||
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)");
|
||||
$labels->push("traefik.http.routers.{$http_label}.entryPoints=http");
|
||||
if ($port) {
|
||||
$labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port");
|
||||
@@ -1111,7 +1130,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
||||
}
|
||||
} else {
|
||||
// Set labels for http
|
||||
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)");
|
||||
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)");
|
||||
$labels->push("traefik.http.routers.{$http_label}.entryPoints=http");
|
||||
if ($port) {
|
||||
$labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port");
|
||||
|
||||
@@ -196,18 +196,38 @@ function validateComposeArrayVolumeSource(string $source): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a top-level network `name:` that is one whole Compose variable (`${VAR}`, `${VAR:-default}`
|
||||
* or `${VAR-default}`), such as an external network that differs per server.
|
||||
* Matches one braced Compose variable: `${VAR}`, `${VAR:-default}`, `${VAR-default}`,
|
||||
* `${VAR:?error}`, `${VAR?error}`, `${VAR:+value}` or `${VAR+value}`.
|
||||
*/
|
||||
const COMPOSE_NETWORK_VARIABLE_PATTERN = '\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?([-?+])([^}]*))?\}';
|
||||
|
||||
/**
|
||||
* Splits a top-level network `name:` that is one whole Compose variable (see
|
||||
* COMPOSE_NETWORK_VARIABLE_PATTERN), such as an external network that differs per server. Only the
|
||||
* `-` operators give a default; the text after `?` is an error message and after `+` a value that
|
||||
* Compose uses only when the variable is set.
|
||||
*
|
||||
* @return array{variable: string, default: ?string}|null
|
||||
*/
|
||||
function composeNetworkNameVariable(string $name): ?array
|
||||
{
|
||||
if (preg_match('/\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}\z/', $name, $matches) !== 1) {
|
||||
if (preg_match('/\A'.COMPOSE_NETWORK_VARIABLE_PATTERN.'\z/', $name, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['variable' => $matches[1], 'default' => $matches[2] ?? null];
|
||||
return ['variable' => $matches[1], 'default' => ($matches[2] ?? '') === '-' ? $matches[3] : null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if the text after the operator of a Compose variable is safe. A default or alternative value
|
||||
* becomes part of the network name; an error message only shows in the Compose error, so it can
|
||||
* also contain spaces and simple punctuation.
|
||||
*/
|
||||
function isSafeComposeNetworkVariableOperand(string $operator, string $operand): bool
|
||||
{
|
||||
$pattern = $operator === '?' ? '/\A[A-Za-z0-9 _.,:!-]*\z/' : '/\A[A-Za-z0-9_.-]*\z/';
|
||||
|
||||
return preg_match($pattern, $operand) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,20 +274,19 @@ function validateComposeNetworkNameField(string $name): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} with safe defaults in a network value that Compose interpolates.
|
||||
* Allows $VAR and the braced variables of COMPOSE_NETWORK_VARIABLE_PATTERN with safe operands in a network value that Compose interpolates.
|
||||
*/
|
||||
function validateComposeNetworkNameWithVariables(string $name, string $context, ?string $serviceName = null): void
|
||||
{
|
||||
$variable = composeNetworkNameVariable($name);
|
||||
if ($variable !== null) {
|
||||
$isValid = $variable['default'] === null || ValidationPatterns::isValidDockerNetwork($variable['default']);
|
||||
if ($variable !== null && $variable['default'] !== null) {
|
||||
$isValid = ValidationPatterns::isValidDockerNetwork($variable['default']);
|
||||
} else {
|
||||
$hasSafeDefaults = true;
|
||||
$withoutVariables = preg_replace_callback(
|
||||
'/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$[A-Za-z_][A-Za-z0-9_]*/',
|
||||
'/'.COMPOSE_NETWORK_VARIABLE_PATTERN.'|\$[A-Za-z_][A-Za-z0-9_]*/',
|
||||
function (array $matches) use (&$hasSafeDefaults): string {
|
||||
$default = $matches[2] ?? '';
|
||||
if ($default !== '' && preg_match('/\A[A-Za-z0-9_.-]+\z/', $default) !== 1) {
|
||||
if (! isSafeComposeNetworkVariableOperand($matches[2] ?? '', $matches[3] ?? '')) {
|
||||
$hasSafeDefaults = false;
|
||||
}
|
||||
|
||||
|
||||
+102
-22
@@ -72,6 +72,7 @@ use phpseclib3\Crypt\EC;
|
||||
use phpseclib3\Crypt\RSA;
|
||||
use Poliander\Cron\CronExpression;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSResult;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
@@ -1494,19 +1495,9 @@ function sslip(Server $server)
|
||||
if (isDev() && $server->id === 0) {
|
||||
return 'http://127.0.0.1.sslip.io';
|
||||
}
|
||||
if ($server->ip === 'host.docker.internal') {
|
||||
$baseIp = base_ip();
|
||||
$ip = $server->ip === 'host.docker.internal' ? base_ip() : $server->ip;
|
||||
|
||||
return "http://$baseIp.sslip.io";
|
||||
}
|
||||
// ipv6
|
||||
if (str($server->ip)->contains(':')) {
|
||||
$ipv6 = str($server->ip)->replace(':', '-');
|
||||
|
||||
return "http://{$ipv6}.sslip.io";
|
||||
}
|
||||
|
||||
return "http://{$server->ip}.sslip.io";
|
||||
return 'http://'.sslipHostLabel($ip).'.sslip.io';
|
||||
}
|
||||
|
||||
function service_templates_cache_key(): string
|
||||
@@ -2352,10 +2343,8 @@ function validateDNSEntry(string $fqdn, Server $server)
|
||||
$type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
|
||||
foreach ($dns_servers as $dns_server) {
|
||||
try {
|
||||
$query = createDnsQuery($dns_server);
|
||||
$results = $query->query($host, $type);
|
||||
if ($results === false || $query->hasError()) {
|
||||
} else {
|
||||
$results = queryDnsServer($dns_server, $host, $type);
|
||||
if ($results !== false) {
|
||||
foreach ($results as $result) {
|
||||
if ($result->getType() == $type) {
|
||||
if (isCloudflareIp($result->getData())) {
|
||||
@@ -2376,15 +2365,40 @@ function validateDNSEntry(string $fqdn, Server $server)
|
||||
return $found_matching_ip;
|
||||
}
|
||||
|
||||
function createDnsQuery(string $dnsServer): DNSQuery
|
||||
function createDnsQuery(string $dnsServer, int $timeout = 5, bool $udp = true): DNSQuery
|
||||
{
|
||||
return app()->make(DNSQuery::class, [
|
||||
'server' => $dnsServer,
|
||||
'server' => formatHostForUrl($dnsServer),
|
||||
'port' => 53,
|
||||
'timeout' => 5,
|
||||
'timeout' => $timeout,
|
||||
'udp' => $udp,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query one DNS server for one record type. When the UDP answer is truncated (TC bit),
|
||||
* retry over TCP: resolvers such as 1.1.1.1 can truncate even small answers, and the
|
||||
* records would otherwise be silently dropped.
|
||||
*
|
||||
* @return iterable<int, DNSResult>|false
|
||||
*/
|
||||
function queryDnsServer(string $dnsServer, string $host, string $type, int $timeout = 5): iterable|false
|
||||
{
|
||||
$query = createDnsQuery($dnsServer, $timeout);
|
||||
$records = $query->query($host, $type);
|
||||
|
||||
if ($records === false && str_contains($query->getLasterror(), 'too big for UDP')) {
|
||||
$query = createDnsQuery($dnsServer, $timeout, udp: false);
|
||||
$records = $query->query($host, $type);
|
||||
}
|
||||
|
||||
if ($records === false || $query->hasError()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
function isCloudflareIp(string $ip): bool
|
||||
{
|
||||
// https://www.cloudflare.com/ips/
|
||||
@@ -2400,6 +2414,72 @@ function isCloudflareIp(string $ip): bool
|
||||
return ipMatch($ip, $cloudflareIps);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one stored form of an IP address: no spaces, no brackets, and IPv6 in its short lower-case
|
||||
* form (2A01:04F8::0001 becomes 2a01:4f8::1). IPv4 addresses and hostnames stay as they are.
|
||||
*/
|
||||
function normalizeIpAddress(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$withoutBrackets = trim($value, '[]');
|
||||
if (filter_var($withoutBrackets, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||
return inet_ntop(inet_pton($withoutBrackets));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host for a URL or a "host:port" value: an IPv6 address gets brackets ([2a01:4f8::1]), so
|
||||
* that its colons are not read as the port. Other hosts stay as they are.
|
||||
*/
|
||||
function formatHostForUrl(string $host): string
|
||||
{
|
||||
$normalized = normalizeIpAddress($host);
|
||||
|
||||
return filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? "[{$normalized}]" : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hetzner gives an IPv6 network (2a01:4f8:c016:bd23::/64), not an address. The server uses the
|
||||
* first address of the network (2a01:4f8:c016:bd23::1).
|
||||
*/
|
||||
function hetznerServerIpv6(?string $network): ?string
|
||||
{
|
||||
if (blank($network)) {
|
||||
return null;
|
||||
}
|
||||
$address = str($network)->before('/')->value();
|
||||
if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
|
||||
return null;
|
||||
}
|
||||
if (str_contains($network, '/')) {
|
||||
$address = inet_ntop(inet_pton($address) | inet_pton('::1'));
|
||||
}
|
||||
|
||||
return normalizeIpAddress($address);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sslip.io label for an IP address. IPv6 colons become dashes, and a "0" is added where the
|
||||
* address starts or ends with "::", because a DNS label cannot start or end with a dash.
|
||||
*/
|
||||
function sslipHostLabel(string $ip): string
|
||||
{
|
||||
$ip = normalizeIpAddress($ip);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
|
||||
return $ip;
|
||||
}
|
||||
if (str_starts_with($ip, '::')) {
|
||||
$ip = '0'.$ip;
|
||||
}
|
||||
if (str_ends_with($ip, '::')) {
|
||||
$ip .= '0';
|
||||
}
|
||||
|
||||
return str_replace(':', '-', $ip);
|
||||
}
|
||||
|
||||
function ipMatch($ip, $cidrs, &$match = null)
|
||||
{
|
||||
foreach ((array) $cidrs as $cidr) {
|
||||
@@ -2508,8 +2588,8 @@ function checkIPAgainstAllowlist($ip, $allowlist)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Direct IP comparison
|
||||
if ($ip === $allowed) {
|
||||
// Direct IP comparison; IPv6 can be written in more than one form
|
||||
if (normalizeIpAddress((string) $ip) === normalizeIpAddress((string) $allowed)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4713,7 +4793,7 @@ function parseScpStyleGitUrl(?string $gitRepository): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^(?<user>[A-Za-z0-9._-]+)@(?<host>[^:]+):(?:(?<port>\d+)\/)?(?<path>.+)$/', $gitRepository, $matches) !== 1) {
|
||||
if (preg_match('/^(?<user>[A-Za-z0-9._-]+)@(?<host>\[[0-9A-Fa-f:.]+\]|[^:\[\]]+):(?:(?<port>\d+)\/)?(?<path>.+)$/', $gitRepository, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Store every IPv6 server address in its short lower-case form, repair Hetzner servers whose
|
||||
* IPv6 network (2a01:4f8:c016:bd23::/64) was saved as 2a01:4f8:c016:bd23::64, and add the
|
||||
* missing brackets to Sentinel URLs that were saved as http://2a01:4f8::1:8000.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('servers')
|
||||
->select(['id', 'team_id', 'ip', 'hetzner_server_id'])
|
||||
->where('ip', 'like', '%:%')
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($servers): void {
|
||||
foreach ($servers as $server) {
|
||||
$this->normalizeServerIp($server);
|
||||
}
|
||||
});
|
||||
|
||||
DB::table('server_settings')
|
||||
->select(['id', 'sentinel_custom_url'])
|
||||
->where('sentinel_custom_url', 'like', '%://%:%:%')
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($settings): void {
|
||||
foreach ($settings as $setting) {
|
||||
$this->bracketSentinelUrlHost($setting);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
private function normalizeServerIp(object $server): void
|
||||
{
|
||||
$ip = (string) $server->ip;
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newIp = normalizeIpAddress($ip);
|
||||
// A Hetzner server that really uses ::64 is reachable; the broken network address is not.
|
||||
$isReachable = (bool) DB::table('server_settings')->where('server_id', $server->id)->value('is_reachable');
|
||||
if (! is_null($server->hetzner_server_id) && str_ends_with($ip, '::64') && ! $isReachable) {
|
||||
$newIp = hetznerServerIpv6(substr($ip, 0, -strlen('::64')).'::/64') ?? $newIp;
|
||||
}
|
||||
|
||||
if ($newIp === $ip) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Coolify does not allow the same server IP twice, in any team.
|
||||
$duplicateExists = DB::table('servers')
|
||||
->whereNull('deleted_at')
|
||||
->where('id', '!=', $server->id)
|
||||
->where('ip', $newIp)
|
||||
->exists();
|
||||
if ($duplicateExists) {
|
||||
Log::warning('Skipped normalizing a server IPv6 address because another server already uses it.', [
|
||||
'server_id' => $server->id,
|
||||
'team_id' => $server->team_id,
|
||||
'ip' => $ip,
|
||||
'normalized_ip' => $newIp,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('servers')->where('id', $server->id)->update(['ip' => $newIp]);
|
||||
}
|
||||
|
||||
private function bracketSentinelUrlHost(object $setting): void
|
||||
{
|
||||
if (preg_match('#\A(https?)://([0-9A-Fa-f:.]+):(\d+)(/.*)?\z#', (string) $setting->sentinel_custom_url, $matches) !== 1) {
|
||||
return;
|
||||
}
|
||||
if (filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('server_settings')->where('id', $setting->id)->update([
|
||||
'sentinel_custom_url' => $matches[1].'://'.formatHostForUrl($matches[2]).':'.$matches[3].($matches[4] ?? ''),
|
||||
]);
|
||||
}
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Redis databases created or cloned in the UI or API since v4.4.0 got two REDIS_USERNAME variables.
|
||||
* Older Docker Compose versions (for example v2.22) refuse to start the container, and newer ones
|
||||
* (v2.29 and later) use the last entry. Each start writes the value of one of them to all of them, so values differ only after
|
||||
* an edit since the last start.
|
||||
* Keep the most recently changed variable of each database, which is the latest edit of the user.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$duplicatedDatabaseIds = DB::table('environment_variables')
|
||||
->where('resourceable_type', StandaloneRedis::class)
|
||||
->where('key', 'REDIS_USERNAME')
|
||||
->groupBy('resourceable_id')
|
||||
->havingRaw('count(*) > 1')
|
||||
->pluck('resourceable_id');
|
||||
|
||||
foreach ($duplicatedDatabaseIds as $databaseId) {
|
||||
$variables = DB::table('environment_variables')
|
||||
->where('resourceable_type', StandaloneRedis::class)
|
||||
->where('resourceable_id', $databaseId)
|
||||
->where('key', 'REDIS_USERNAME')
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('id')
|
||||
->pluck('id');
|
||||
|
||||
DB::table('environment_variables')->whereIn('id', $variables->slice(1)->all())->delete();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Cloned Nixpacks applications got the default NIXPACKS_NODE_VERSION next to the copied one,
|
||||
* for production and for preview. Keep the most recently changed variable of each application
|
||||
* and preview flag: the latest edit of the user, else the copied value (it has the higher id).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$duplicates = DB::table('environment_variables')
|
||||
->where('resourceable_type', Application::class)
|
||||
->where('key', 'NIXPACKS_NODE_VERSION')
|
||||
->select('resourceable_id', 'is_preview')
|
||||
->groupBy('resourceable_id', 'is_preview')
|
||||
->havingRaw('count(*) > 1')
|
||||
->get();
|
||||
|
||||
foreach ($duplicates as $duplicate) {
|
||||
$variables = DB::table('environment_variables')
|
||||
->where('resourceable_type', Application::class)
|
||||
->where('resourceable_id', $duplicate->resourceable_id)
|
||||
->where('is_preview', $duplicate->is_preview)
|
||||
->where('key', 'NIXPACKS_NODE_VERSION')
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('id')
|
||||
->pluck('id');
|
||||
|
||||
DB::table('environment_variables')->whereIn('id', $variables->slice(1)->all())->delete();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -117,14 +117,14 @@
|
||||
</a>
|
||||
@else
|
||||
<a class="{{ $linkItemClasses }}" target="_blank"
|
||||
href="http://{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}">
|
||||
<span class="min-w-0 truncate">{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}</span>
|
||||
href="http://{{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }}">
|
||||
<span class="min-w-0 truncate">{{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }}</span>
|
||||
</a>
|
||||
@if (count($application->additional_servers) > 0)
|
||||
@foreach ($application->additional_servers as $server)
|
||||
<a class="{{ $linkItemClasses }}" target="_blank"
|
||||
href="http://{{ $server->ip }}:{{ explode(':', $port)[0] }}">
|
||||
<span class="min-w-0 truncate">{{ $server->ip }}:{{ explode(':', $port)[0] }}</span>
|
||||
href="http://{{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }}">
|
||||
<span class="min-w-0 truncate">{{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@@ -261,13 +261,13 @@
|
||||
@if ($deploymentsIndicator)
|
||||
<livewire:deployments-indicator variant="sidebar" />
|
||||
@endif
|
||||
<div class="flex items-center gap-1" :class="collapsed ? 'flex-col-reverse justify-center' : 'justify-between'">
|
||||
<div class="flex items-center gap-1" :class="collapsed ? 'flex-col justify-center' : 'justify-between'">
|
||||
<x-top-user-menu sidebar />
|
||||
<button type="button" @click="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar"
|
||||
class="menu-item sidebar-toggle w-8 shrink-0 justify-center px-0">
|
||||
<svg class="menu-item-icon" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
|
||||
<rect x="3" y="5" width="18" height="14" rx="4" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M16 9v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
$userEmail = $user?->email ?? '';
|
||||
$userInitial = strtoupper(mb_substr($user?->name ?: ($user?->email ?: 'A'), 0, 1));
|
||||
@endphp
|
||||
<div @class(['relative', 'min-w-0' => $sidebar]) x-data="{
|
||||
<div @class(['relative', 'min-w-0 flex-1' => $sidebar]) x-data="{
|
||||
open: false,
|
||||
appearanceOpen: false,
|
||||
avatarUrl: @js($user?->avatar_path ? profile_avatar_url($user) : null),
|
||||
@@ -23,10 +23,10 @@
|
||||
@click.outside="closePanel()">
|
||||
<button type="button" @click="open ? closePanel() : openPanel()"
|
||||
title="{{ $userName }}" aria-label="Account menu for {{ $userName }}"
|
||||
@if ($sidebar) :class="collapsed && 'w-8 justify-center px-0'" @endif
|
||||
@if ($sidebar) :class="collapsed ? 'w-8 px-0' : 'w-full'" @endif
|
||||
@class([
|
||||
'flex h-8 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 shadow-sm transition-colors hover:bg-neutral-200 dark:border-white/[0.08] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]',
|
||||
'max-w-36' => $sidebar,
|
||||
'button rounded-lg px-2 normal-case',
|
||||
'min-w-0' => $sidebar,
|
||||
])>
|
||||
<img x-cloak x-show="avatarUrl" :src="avatarUrl" alt="{{ $userName }}"
|
||||
class="size-5 shrink-0 rounded-full object-cover">
|
||||
@@ -35,7 +35,7 @@
|
||||
{{ $userInitial }}
|
||||
</span>
|
||||
@if ($sidebar)
|
||||
<span class="min-w-0 truncate text-xs font-medium" :class="collapsed && 'hidden'">{{ $userName }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-left text-xs font-medium" :class="collapsed && 'hidden'">{{ $userName }}</span>
|
||||
@endif
|
||||
<svg class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint transition-transform"
|
||||
:class="[open && 'rotate-180', {{ $sidebar ? "collapsed && 'hidden'" : 'false' }}]"
|
||||
@@ -88,8 +88,8 @@
|
||||
:aria-pressed="autoCollapse" title="Collapse the sidebar on pages that have a settings menu">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="size-4 opacity-80" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
|
||||
<rect x="3" y="5" width="18" height="14" rx="4" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M16 9v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
Auto-collapse sidebar
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<x-emails.layout>
|
||||
Coolify did not update to {{ $version }} because the server does not have enough free disk space ({{ $available_gb }} GB free, {{ $required_gb }} GB required).
|
||||
|
||||
Free up disk space, for example with a [Docker cleanup]({{ $cleanup_url }}). The next automatic update will try again.
|
||||
</x-emails.layout>
|
||||
@@ -173,8 +173,8 @@
|
||||
class="-mr-1 flex size-9 items-center justify-center rounded-md text-neutral-500 transition-transform duration-100 ease-out hover:bg-neutral-100 hover:text-black active:scale-90 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="size-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
|
||||
<rect x="3" y="5" width="18" height="14" rx="4" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M16 9v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -211,9 +211,9 @@
|
||||
class="relative block truncate text-[13px] font-semibold text-black hover:underline dark:text-fg"
|
||||
x-text="item.name"></a>
|
||||
</div>
|
||||
<p class="min-h-4 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
<span x-show="item.description" x-text="item.description"></span>
|
||||
</p>
|
||||
<p class="truncate text-[11px] text-neutral-500 dark:text-fg-faint"
|
||||
x-show="item.description?.trim()"
|
||||
x-text="item.description"></p>
|
||||
<div class="mobile-resource-domain min-w-0">
|
||||
<template x-if="item.fqdn">
|
||||
<a :href="firstDomain(item.fqdn)" target="_blank" rel="noopener noreferrer"
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
Create DNS records automatically when a new domain has one unambiguous matching credential.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="edit-proxied-dns" label="Proxy DNS records through Cloudflare" fullWidth
|
||||
wire:model.live="proxiedDns" canGate="update" :canResource="$integrationToken" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Create new DNS records with the Cloudflare proxy on (orange cloud), so the server IP is not public. Existing records keep their proxy status.
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
Create DNS records automatically when a new domain has one unambiguous matching credential.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="proxied-dns" label="Proxy DNS records through Cloudflare" fullWidth
|
||||
wire:model.live="proxiedDns" canGate="create" :canResource="\App\Models\IntegrationToken::class" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Create new DNS records with the Cloudflare proxy on (orange cloud), so the server IP is not public. Existing records keep their proxy status.
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
<x-server.status-summary :server="$server" :proxy-status="$proxyStatus"
|
||||
:show-sentinel-status="$showSentinelStatus" />
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a target="_blank" href="http://{{ $serverIp }}:8080" title="Open Traefik dashboard"
|
||||
<a target="_blank" href="http://{{ formatHostForUrl((string) $serverIp) }}:8080" title="Open Traefik dashboard"
|
||||
class="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 text-xs font-medium leading-none text-neutral-700 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-white">
|
||||
<x-reicon name="external-link" class="size-3" />
|
||||
Traefik
|
||||
@@ -282,7 +282,7 @@
|
||||
@if ($traefikDashboardAvailable)
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<a class="app-tab shrink-0 gap-1" target="_blank" title="Open Traefik dashboard"
|
||||
href="http://{{ $serverIp }}:8080">
|
||||
href="http://{{ formatHostForUrl((string) $serverIp) }}:8080">
|
||||
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
|
||||
Traefik Dashboard
|
||||
</a>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
{{ $github_app->name ?: 'GitHub App' }} | Sources | Coolify
|
||||
</x-slot>
|
||||
|
||||
@if (session('error'))
|
||||
<span x-data x-init="$wire.dispatch('error', @js(session('error')))" />
|
||||
@endif
|
||||
|
||||
@if (data_get($github_app, 'app_id'))
|
||||
@php
|
||||
$githubAppRouteParameters = ['github_app_uuid' => $github_app->uuid];
|
||||
|
||||
@@ -137,8 +137,53 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Low Disk Space View --}}
|
||||
<template x-if="!showProgress && lowDiskSpace">
|
||||
<div class="flex flex-col gap-4">
|
||||
<x-callout type="danger" title="Not enough free disk space">
|
||||
<p>
|
||||
The server has <span class="font-semibold" x-text="availableGb + ' GB'"></span> free.
|
||||
The upgrade needs at least <span class="font-semibold" x-text="requiredGb + ' GB'"></span>.
|
||||
If the disk becomes full during the upgrade, Coolify can stop working.
|
||||
</p>
|
||||
</x-callout>
|
||||
|
||||
<p class="text-[12px] leading-5" style="color: var(--coollabs-subtle)">
|
||||
Run a Docker cleanup to remove unused images, containers, and build cache, then check again.
|
||||
</p>
|
||||
|
||||
<template x-if="confirmOverride">
|
||||
<x-callout type="warning" title="Upgrade anyway?">
|
||||
<p>Only continue if you are sure that the upgrade has enough disk space.</p>
|
||||
</x-callout>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<template x-if="!confirmOverride">
|
||||
<x-forms.button @click="confirmOverride = true" type="button">
|
||||
Upgrade anyway
|
||||
</x-forms.button>
|
||||
</template>
|
||||
<template x-if="confirmOverride">
|
||||
<x-forms.button @click="confirmed(true)" type="button"
|
||||
x-bind:disabled="checkingDiskSpace">
|
||||
Yes, upgrade anyway
|
||||
</x-forms.button>
|
||||
</template>
|
||||
<x-forms.button @click="$wire.runDockerCleanup()" type="button">
|
||||
Run Docker cleanup
|
||||
</x-forms.button>
|
||||
<x-forms.button @click="checkDiskSpaceAgain()" isHighlighted type="button"
|
||||
x-bind:disabled="checkingDiskSpace">
|
||||
<span x-text="checkingDiskSpace ? 'Checking…' : 'Check again'"></span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Confirmation View --}}
|
||||
<template x-if="!showProgress">
|
||||
<template x-if="!showProgress && !lowDiskSpace">
|
||||
<div class="flex flex-col gap-4">
|
||||
<x-callout type="warning" title="Caution">
|
||||
<p>Any deployments running during the update process will fail.</p>
|
||||
@@ -160,8 +205,9 @@
|
||||
Simulate
|
||||
</x-forms.button>
|
||||
</template>
|
||||
<x-forms.button @click="confirmed" isHighlighted type="button">
|
||||
Upgrade now
|
||||
<x-forms.button @click="confirmed()" isHighlighted type="button"
|
||||
x-bind:disabled="checkingDiskSpace">
|
||||
<span x-text="checkingDiskSpace ? 'Checking disk space…' : 'Upgrade now'"></span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,6 +248,11 @@
|
||||
instanceWentDown: false,
|
||||
devMode: config.devMode || false,
|
||||
simulationInterval: null,
|
||||
lowDiskSpace: false,
|
||||
availableGb: null,
|
||||
requiredGb: null,
|
||||
confirmOverride: false,
|
||||
checkingDiskSpace: false,
|
||||
|
||||
simulateUpgrade() {
|
||||
if (!this.devMode) return;
|
||||
@@ -234,13 +285,30 @@
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
confirmed() {
|
||||
async confirmed(skipDiskSpaceCheck = false) {
|
||||
if (this.checkingDiskSpace) return;
|
||||
this.checkingDiskSpace = true;
|
||||
let result;
|
||||
try {
|
||||
// Trigger server-side upgrade script via Livewire. It does not start when the disk is almost full.
|
||||
result = await this.$wire.upgrade(skipDiskSpaceCheck);
|
||||
} finally {
|
||||
this.checkingDiskSpace = false;
|
||||
}
|
||||
if (result?.status === 'low_disk_space') {
|
||||
this.lowDiskSpace = true;
|
||||
this.availableGb = result.available_gb;
|
||||
this.requiredGb = result.required_gb;
|
||||
return;
|
||||
}
|
||||
if (result?.status !== 'started') return;
|
||||
|
||||
this.lowDiskSpace = false;
|
||||
this.confirmOverride = false;
|
||||
this.showProgress = true;
|
||||
this.currentStep = 1;
|
||||
this.currentStatus = 'Starting upgrade...';
|
||||
this.startTimer();
|
||||
// Trigger server-side upgrade script via Livewire
|
||||
this.$wire.$call('upgrade');
|
||||
// Start client-side status polling
|
||||
this.upgrade();
|
||||
// Prevent accidental navigation during upgrade
|
||||
@@ -251,6 +319,22 @@
|
||||
window.addEventListener('beforeunload', this.beforeUnloadHandler);
|
||||
},
|
||||
|
||||
async checkDiskSpaceAgain() {
|
||||
if (this.checkingDiskSpace) return;
|
||||
this.checkingDiskSpace = true;
|
||||
try {
|
||||
const result = await this.$wire.checkDiskSpace();
|
||||
this.availableGb = result.available_gb;
|
||||
this.requiredGb = result.required_gb;
|
||||
this.lowDiskSpace = result.status === 'low_disk_space';
|
||||
if (!this.lowDiskSpace) {
|
||||
this.confirmOverride = false;
|
||||
}
|
||||
} finally {
|
||||
this.checkingDiskSpace = false;
|
||||
}
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
this.startTime = Date.now();
|
||||
this.elapsedInterval = setInterval(() => {
|
||||
|
||||
@@ -13,6 +13,8 @@ else
|
||||
REGISTRY_URL="docker.io"
|
||||
fi
|
||||
SKIP_BACKUP=${4:-false}
|
||||
SKIP_DISK_SPACE_CHECK=${5:-false}
|
||||
REQUIRED_DISK_SPACE_GB=5
|
||||
STATUS_FILE="/data/coolify/source/.upgrade-status"
|
||||
|
||||
DATE=$(date +%Y-%m-%d-%H-%M-%S)
|
||||
@@ -53,6 +55,24 @@ echo "Helper Version: ${LATEST_HELPER_VERSION}" >>"$LOGFILE"
|
||||
echo "Registry URL: ${REGISTRY_URL}" >>"$LOGFILE"
|
||||
echo "============================================================" >>"$LOGFILE"
|
||||
|
||||
# Stop before anything changes when the disk is almost full.
|
||||
# A full disk during the upgrade can leave Coolify broken.
|
||||
if [ "$SKIP_DISK_SPACE_CHECK" != "true" ]; then
|
||||
DOCKER_ROOT_DIR=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)
|
||||
AVAILABLE_KB=$(df -Pk /data/coolify "${DOCKER_ROOT_DIR:-/var/lib/docker}" 2>/dev/null | awk 'NR>1 && $4 ~ /^[0-9]+$/ {print $4}' | sort -n | head -n1)
|
||||
if [ -n "$AVAILABLE_KB" ] && [ "$AVAILABLE_KB" -lt $((REQUIRED_DISK_SPACE_GB * 1024 * 1024)) ]; then
|
||||
AVAILABLE_GB=$(awk -v kb="$AVAILABLE_KB" 'BEGIN {printf "%.1f", kb / 1024 / 1024}')
|
||||
log "ERROR: Not enough free disk space: ${AVAILABLE_GB} GB free, ${REQUIRED_DISK_SPACE_GB} GB required"
|
||||
write_status "error" "Not enough free disk space: ${AVAILABLE_GB} GB free, ${REQUIRED_DISK_SPACE_GB} GB required. Free up disk space and try again."
|
||||
echo " ERROR: Not enough free disk space: ${AVAILABLE_GB} GB free, ${REQUIRED_DISK_SPACE_GB} GB required."
|
||||
echo " Free up disk space (for example: docker system prune) and try again."
|
||||
exit 1
|
||||
fi
|
||||
log "Disk space check passed (${AVAILABLE_KB:-unknown} KB free)"
|
||||
else
|
||||
log "Disk space check skipped"
|
||||
fi
|
||||
|
||||
log_section "Step 1/6: Downloading configuration files"
|
||||
write_status "1" "Downloading configuration files"
|
||||
echo "1/6 Downloading latest configuration files..."
|
||||
|
||||
@@ -124,3 +124,20 @@ test('only returns domains for applications on the specified server', function (
|
||||
expect($allDomains)->toContain('app-on-server.example.com');
|
||||
expect($allDomains)->not->toContain('app-on-other-server.example.com');
|
||||
});
|
||||
|
||||
test('returns IPv6 hosts with brackets and without the port', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
Application::query()->whereKey($application->id)->update([
|
||||
'fqdn' => 'http://[2a01:4f8::1]:8080,https://app.example.com:8443/api,http://192.0.2.10:3000',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(domainApiAuthHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('0.domains'))->toBe(['[2a01:4f8::1]', 'app.example.com', '192.0.2.10']);
|
||||
});
|
||||
|
||||
@@ -669,6 +669,47 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
$response->assertJsonFragment(['ip' => '2001:db8::1']);
|
||||
});
|
||||
|
||||
test('stores the server address of the Hetzner IPv6 network', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => null],
|
||||
'ipv6' => ['ip' => '2a01:4f8:c016:bd23::/64'],
|
||||
],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv4' => false,
|
||||
'enable_ipv6' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['ip' => '2a01:4f8:c016:bd23::1']);
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'hetzner_server_id' => '456',
|
||||
'ip' => '2a01:4f8:c016:bd23::1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects server creation when both public IP protocols are disabled', function () {
|
||||
Http::fake();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Database\BackupMissing;
|
||||
@@ -169,6 +170,20 @@ describe('POST /api/v1/databases/{uuid}/clone', function () {
|
||||
->and(NotificationThrottle::wasSent($clonedBackup, BackupMissing::class))->toBeFalse();
|
||||
});
|
||||
|
||||
test('clones a Redis database with one REDIS_USERNAME variable', function () {
|
||||
$database = create_standalone_redis($this->environment->id, $this->destination, ['image' => 'redis:7.2']);
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/databases/{$database->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$cloned = StandaloneRedis::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
expect($cloned->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(1)
|
||||
->and($cloned->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('creates renamed volumes when cloning a database with clone_volumes', function () {
|
||||
// Prevent remote stop/clone/start from running in unit tests.
|
||||
Bus::fake();
|
||||
|
||||
@@ -133,12 +133,12 @@ it('does not query environment variables per Redis database in the environment d
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a missing Redis username variable only once when the variables are loaded', function () {
|
||||
it('reads a missing Redis username as default without creating a variable', function () {
|
||||
$redis = create_standalone_redis($this->environment->id, $this->destination);
|
||||
$redis->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->delete();
|
||||
$redis->load('runtime_environment_variables');
|
||||
|
||||
expect($redis->redis_username)->toBe('default')
|
||||
->and($redis->redis_username)->toBe('default')
|
||||
->and($redis->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(1);
|
||||
->and($redis->fresh()->redis_username)->toBe('default')
|
||||
->and($redis->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -932,3 +932,32 @@ describe('GitHub App name sync after the user switches the session team', functi
|
||||
expect($this->githubApp->fresh()->name)->toBe('synced-app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub webhook endpoint options for IPv6 instance addresses', function () {
|
||||
test('brackets the public IPv6 address and keeps IPv4 unchanged', function (string $ipv6) {
|
||||
config(['app.port' => 8000]);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'public_ipv4' => '203.0.113.10',
|
||||
'public_ipv6' => $ipv6,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSet('ipv4', 'http://203.0.113.10:8000')
|
||||
->assertSet('ipv6', 'http://[2a01:4f8::1]:8000');
|
||||
})->with([
|
||||
'raw' => '2a01:4f8::1',
|
||||
'bracketed' => '[2a01:4f8::1]',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3679,3 +3679,20 @@ it('does not queue file sync jobs when polling dns checks for compose applicatio
|
||||
->and(Queue::pushed(ServerFilesFromServerJob::class))->toHaveCount(1)
|
||||
->and($component->get('composeServices'))->toBe(['web', 'api']);
|
||||
});
|
||||
|
||||
it('does not suggest a www counterpart for bare IP hosts', function (string $url) {
|
||||
$wwwCounterpartUrl = new ReflectionMethod(Domains::class, 'wwwCounterpartUrl');
|
||||
|
||||
expect($wwwCounterpartUrl->invoke(new Domains, $url))->toBeNull()
|
||||
->and($wwwCounterpartUrl->invoke(new Domains, $url, true))->toBeNull();
|
||||
})->with([
|
||||
'IPv4' => 'http://192.0.2.10:3000',
|
||||
'IPv6' => 'http://[2a01:4f8::1]',
|
||||
'IPv6 with port' => 'http://[2a01:4f8::1]:8080/app',
|
||||
]);
|
||||
|
||||
it('still suggests a www counterpart for a hostname', function () {
|
||||
$wwwCounterpartUrl = new ReflectionMethod(Domains::class, 'wwwCounterpartUrl');
|
||||
|
||||
expect($wwwCounterpartUrl->invoke(new Domains, 'https://example.com:8443/app'))->toBe('https://www.example.com:8443/app');
|
||||
});
|
||||
|
||||
@@ -170,6 +170,28 @@ YAML);
|
||||
expect($application->environment_variables()->where('key', 'SHARED_NETWORK')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('required network name variables are created without their error message as value', function () {
|
||||
$application = makeComposeApplicationWithNetworks(<<<'YAML'
|
||||
shared:
|
||||
external: true
|
||||
name: ${SHARED_NETWORK?error}
|
||||
other:
|
||||
name: ${OTHER_NETWORK:+other-network}
|
||||
plain:
|
||||
name: plain-network
|
||||
YAML);
|
||||
|
||||
$application->parse();
|
||||
|
||||
$variables = $application->environment_variables()->get()->keyBy('key');
|
||||
|
||||
expect($variables->has('SHARED_NETWORK'))->toBeTrue()
|
||||
->and((string) $variables->get('SHARED_NETWORK')->value)->toBe('')
|
||||
->and($variables->has('OTHER_NETWORK'))->toBeTrue()
|
||||
->and((string) $variables->get('OTHER_NETWORK')->value)->toBe('')
|
||||
->and($application->fresh()->docker_compose)->toContain('${SHARED_NETWORK?error}');
|
||||
});
|
||||
|
||||
test('the deployment .env of a compose application contains the network name variable', function () {
|
||||
$application = makeComposeApplicationWithNetworks(<<<'YAML'
|
||||
shared:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Support\AtomicFilesystem;
|
||||
use Illuminate\Foundation\Console\ConfigCacheCommand;
|
||||
use Illuminate\Foundation\Console\RouteCacheCommand;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->directory = sys_get_temp_dir().'/atomic-filesystem-'.bin2hex(random_bytes(4));
|
||||
mkdir($this->directory);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
array_map('unlink', glob($this->directory.'/*'));
|
||||
rmdir($this->directory);
|
||||
});
|
||||
|
||||
it('writes the config and route caches with the atomic filesystem', function (string $command) {
|
||||
$files = (fn () => $this->files)->call(app($command));
|
||||
|
||||
expect($files)->toBeInstanceOf(AtomicFilesystem::class);
|
||||
})->with([
|
||||
'config:cache' => ConfigCacheCommand::class,
|
||||
'route:cache' => RouteCacheCommand::class,
|
||||
]);
|
||||
|
||||
it('replaces the file with a new complete file and leaves no temporary file', function () {
|
||||
$path = $this->directory.'/config.php';
|
||||
file_put_contents($path, '<?php return [];');
|
||||
$oldInode = fileinode($path);
|
||||
$previousUmask = umask(0022);
|
||||
|
||||
try {
|
||||
$bytes = (new AtomicFilesystem)->put($path, "<?php return ['app' => ['name' => 'Coolify']];");
|
||||
} finally {
|
||||
umask($previousUmask);
|
||||
}
|
||||
|
||||
clearstatcache();
|
||||
expect($bytes)->toBe(46)
|
||||
->and(require $path)->toBe(['app' => ['name' => 'Coolify']])
|
||||
->and(fileinode($path))->not->toBe($oldInode)
|
||||
->and(fileperms($path) & 0777)->toBe(0644)
|
||||
->and(glob($this->directory.'/*'))->toBe([$path]);
|
||||
});
|
||||
|
||||
it('does not write the file outside its directory', function () {
|
||||
$path = $this->directory.'/missing/config.php';
|
||||
|
||||
expect(fn () => (new AtomicFilesystem)->put($path, '<?php return [];'))
|
||||
->toThrow(RuntimeException::class, 'Could not create a temporary file');
|
||||
|
||||
expect(file_exists($path))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = $this->server->standaloneDockers()->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('cloning a nixpacks application does not duplicate NIXPACKS_NODE_VERSION', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'build_pack' => 'nixpacks',
|
||||
'redirect' => 'both',
|
||||
]);
|
||||
$application->settings->fill(['is_container_label_readonly_enabled' => false])->save();
|
||||
|
||||
$application->environment_variables()->where('key', 'NIXPACKS_NODE_VERSION')->firstOrFail()->update(['value' => '20']);
|
||||
|
||||
$newApp = clone_application($application, $this->destination, [
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$production = $newApp->environment_variables()->where('key', 'NIXPACKS_NODE_VERSION')->get();
|
||||
$preview = $newApp->environment_variables_preview()->where('key', 'NIXPACKS_NODE_VERSION')->get();
|
||||
|
||||
expect($production)->toHaveCount(1)
|
||||
->and($production->first()->value)->toBe('20')
|
||||
->and($preview)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('the migration keeps the most recently changed NIXPACKS_NODE_VERSION of each application and preview flag', function () {
|
||||
$attributes = [
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'build_pack' => 'nixpacks',
|
||||
];
|
||||
$application = Application::factory()->create($attributes);
|
||||
$other = Application::factory()->create($attributes);
|
||||
|
||||
// The rows an earlier clone_application() added next to the defaults of the created hooks.
|
||||
$copies = collect([false, true])->map(fn (bool $isPreview) => EnvironmentVariable::query()->create([
|
||||
'key' => 'NIXPACKS_NODE_VERSION',
|
||||
'value' => '20',
|
||||
'is_preview' => $isPreview,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
]));
|
||||
EnvironmentVariable::query()->whereIn('id', $copies->pluck('id'))->update(['updated_at' => now()->addMinute()]);
|
||||
|
||||
$migration = require database_path('migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php');
|
||||
$migration->up();
|
||||
|
||||
$rows = EnvironmentVariable::query()
|
||||
->where('resourceable_type', Application::class)
|
||||
->where('key', 'NIXPACKS_NODE_VERSION');
|
||||
|
||||
expect((clone $rows)->where('resourceable_id', $application->id)->pluck('id')->sort()->values()->all())->toBe($copies->pluck('id')->sort()->values()->all())
|
||||
->and((clone $rows)->where('resourceable_id', $other->id)->count())->toBe(2);
|
||||
});
|
||||
@@ -151,6 +151,10 @@ describe('owner resolution', function () {
|
||||
// Deployed before mid-2024: compose project was the deployment directory, the compose service starts with the UUID.
|
||||
->and(resolveContainerOwner($applications, ['coolify.applicationId' => '999999', 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => $this->application->uuid.'-104512123456'], 'application')?->id)->toBe($this->application->id)
|
||||
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'other-instance-uuid-104512123456'], 'application'))->toBeNull()
|
||||
// Compose application with a custom start command: the compose project is the deployment directory, the container name is `{service}-{uuid}-{suffix}`.
|
||||
->and(resolveContainerOwner($applications, ['coolify.applicationId' => '999999', 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'public-api', 'coolify.name' => 'public-api-'.$this->application->uuid.'-104512123456'], 'application')?->id)->toBe($this->application->id)
|
||||
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'public-api', 'coolify.name' => 'public-api-other-instance-uuid-104512123456'], 'application'))->toBeNull()
|
||||
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'coolify.name' => 'public-api-'.$this->application->uuid.'x-104512123456'], 'application'))->toBeNull()
|
||||
->and(implode("\n", dockerPsByOwnerCommands('application', 'app-uuid')))->not->toContain('label=coolify.applicationId=');
|
||||
});
|
||||
|
||||
@@ -163,9 +167,9 @@ describe('owner resolution', function () {
|
||||
->and($commands[1])->toContain("--filter 'label=coolify.applicationId'")
|
||||
->toContain("--filter 'label=com.docker.compose.project=app-uuid'")
|
||||
->toContain("--filter 'label=coolify.pullRequestId=0'")
|
||||
// Deployed before mid-2024: the container name starts with the application UUID.
|
||||
// Compose project is the deployment directory: the container name contains the application UUID.
|
||||
->and($commands[2])->toContain("--filter 'label=coolify.applicationId'")
|
||||
->toContain("--filter 'name=^app-uuid'")
|
||||
->toContain("--filter 'name=app-uuid'")
|
||||
->toContain("--filter 'label=coolify.pullRequestId=0'")
|
||||
->and(dockerPsByOwnerCommands('service', 'service-uuid'))->toHaveCount(2);
|
||||
});
|
||||
@@ -291,6 +295,35 @@ describe('status updates after a server transfer', function () {
|
||||
expect($this->application->fresh()->status)->toStartWith('running');
|
||||
});
|
||||
|
||||
test('Sentinel push and status check match a 4.3 compose container deployed with a custom start command', function () {
|
||||
$containerName = 'public-api-'.$this->application->uuid.'-104512123456';
|
||||
$labels = [
|
||||
'coolify.managed' => 'true',
|
||||
'coolify.applicationId' => (string) $this->application->id,
|
||||
'coolify.pullRequestId' => '0',
|
||||
'coolify.name' => $containerName,
|
||||
// Without --project-name, the compose project is the deployment directory.
|
||||
'com.docker.compose.project' => 'r8wkc0gk0o4ws8gs4s8wwgs4',
|
||||
'com.docker.compose.service' => 'public-api',
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($this->server, ['containers' => [[
|
||||
'name' => $containerName, 'state' => 'running', 'health_status' => 'healthy', 'labels' => $labels,
|
||||
]]]);
|
||||
$job->handle();
|
||||
expect($this->application->fresh()->status)->toStartWith('running');
|
||||
|
||||
$this->application->update(['status' => 'exited']);
|
||||
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
GetContainersStatus::run($this->server->fresh(), collect([[
|
||||
'Name' => '/'.$containerName,
|
||||
'State' => ['Status' => 'running', 'Health' => ['Status' => 'healthy']],
|
||||
'RestartCount' => 0,
|
||||
'Config' => ['Labels' => $labels],
|
||||
]]), collect());
|
||||
expect($this->application->fresh()->status)->toStartWith('running');
|
||||
});
|
||||
|
||||
test('container status check matches an older application container by compose project', function () {
|
||||
$container = [
|
||||
'Name' => '/'.$this->application->uuid,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
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\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Services\DatabaseStartCommandExecutor;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
config(['app.maintenance.store' => 'array', 'cache.default' => 'array', 'constants.ssh.mux_enabled' => false]);
|
||||
// The queued ServerStorageSaveJob does not run, as when it runs after the containers start.
|
||||
Queue::fake();
|
||||
|
||||
// Every path on the server is missing, and every path is inside the resource directory.
|
||||
Process::fake(function ($process) {
|
||||
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
|
||||
if (str_contains($command, 'empty-directory')) {
|
||||
return Process::result(output: collect(range(1, substr_count($command, '/ssl/server.')))->map(fn (int $index) => "{$index}:missing")->implode("\n"));
|
||||
}
|
||||
if (str_contains($command, 'readlink -f')) {
|
||||
return Process::result(output: 'OK');
|
||||
}
|
||||
|
||||
return Process::result(output: str_contains($command, 'test -') ? 'NOK' : '');
|
||||
});
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$this->server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->executor = new class
|
||||
{
|
||||
public array $commands = [];
|
||||
|
||||
public function execute(array $commands, $database, Activity $activity): Activity
|
||||
{
|
||||
$this->commands = $commands;
|
||||
|
||||
return $activity;
|
||||
}
|
||||
};
|
||||
app()->instance(DatabaseStartCommandExecutor::class, $this->executor);
|
||||
});
|
||||
|
||||
dataset('ssl-database-start-actions-with-certificate-files', [
|
||||
'postgresql' => ['create_standalone_postgresql', StartPostgresql::class, ['server.crt', 'server.key']],
|
||||
'mysql' => ['create_standalone_mysql', StartMysql::class, ['server.crt', 'server.key']],
|
||||
'mariadb' => ['create_standalone_mariadb', StartMariadb::class, ['server.crt', 'server.key']],
|
||||
'mongodb' => ['create_standalone_mongodb', StartMongodb::class, ['server.pem']],
|
||||
'redis' => ['create_standalone_redis', StartRedis::class, ['server.crt', 'server.key']],
|
||||
'keydb' => ['create_standalone_keydb', StartKeydb::class, ['server.crt', 'server.key']],
|
||||
'dragonfly' => ['create_standalone_dragonfly', StartDragonfly::class, ['server.crt', 'server.key']],
|
||||
]);
|
||||
|
||||
it('writes a new SSL certificate file on the server before the container starts', function (string $createDatabase, string $startAction, array $files) {
|
||||
$database = $createDatabase($this->environment->id, $this->destination);
|
||||
$database->update(['enable_ssl' => true]);
|
||||
|
||||
$startAction::run($database->fresh(), new Activity);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = escapeshellarg($database->workdir()."/ssl/{$file}");
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "base64 -d | tee {$path}"));
|
||||
}
|
||||
$commands = implode("\n", $this->executor->commands);
|
||||
expect($commands)->toContain('missing configuration file')
|
||||
->and(strpos($commands, 'missing configuration file'))->toBeLessThan(strpos($commands, 'docker-compose.yml up -d'));
|
||||
})->with('ssl-database-start-actions-with-certificate-files');
|
||||
|
||||
it('writes the MongoDB server.pem before the certificate owner change mounts it', function () {
|
||||
$database = create_standalone_mongodb($this->environment->id, $this->destination);
|
||||
$database->update(['enable_ssl' => true]);
|
||||
|
||||
StartMongodb::run($database->fresh(), new Activity);
|
||||
|
||||
$commands = implode("\n", $this->executor->commands);
|
||||
expect($commands)->toContain('Writing 1 missing configuration file.')
|
||||
->and(strpos($commands, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($commands, '--entrypoint chown'));
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartClickhouse;
|
||||
use App\Actions\Database\StartMariadb;
|
||||
use App\Actions\Database\StartMongodb;
|
||||
use App\Actions\Database\StartMysql;
|
||||
use App\Actions\Database\StartPostgresql;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Services\DatabaseStartCommandExecutor;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
||||
Process::fake();
|
||||
Queue::fake();
|
||||
Server::flushIdentityMap();
|
||||
|
||||
$team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->executor = new class
|
||||
{
|
||||
public array $commands = [];
|
||||
|
||||
public function execute(array $commands, $database, Activity $activity): Activity
|
||||
{
|
||||
$this->commands = $commands;
|
||||
|
||||
return $activity;
|
||||
}
|
||||
};
|
||||
app()->instance(DatabaseStartCommandExecutor::class, $this->executor);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function databaseStartComposeEnvironment(object $executor, string $uuid): array
|
||||
{
|
||||
foreach ($executor->commands as $command) {
|
||||
if (preg_match("#^echo '([^']+)' \| base64 -d \| tee \S+/docker-compose\.yml#", $command, $matches)) {
|
||||
return Yaml::parse(base64_decode($matches[1]))['services'][$uuid]['environment'];
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('No docker-compose.yml was written.');
|
||||
}
|
||||
|
||||
it('adds the Postgres defaults when user keys or values only contain the default key name', function () {
|
||||
$database = create_standalone_postgresql($this->environment->id, $this->destination, ['postgres_password' => 'model-secret', 'postgres_db' => 'appdb']);
|
||||
$database->runtime_environment_variables()->create(['key' => 'POSTGRES_PASSWORD_FILE', 'value' => '/x']);
|
||||
$database->runtime_environment_variables()->create(['key' => 'NOTE', 'value' => 'uses POSTGRES_DB and POSTGRES_USER']);
|
||||
|
||||
StartPostgresql::run($database->fresh(), new Activity);
|
||||
|
||||
expect(databaseStartComposeEnvironment($this->executor, $database->uuid))
|
||||
->toContain('POSTGRES_PASSWORD_FILE=/x')
|
||||
->toContain('POSTGRES_PASSWORD=model-secret')
|
||||
->toContain('POSTGRES_DB=appdb')
|
||||
->toContain('POSTGRES_USER=postgres');
|
||||
});
|
||||
|
||||
it('does not add a Postgres default when the user sets the exact key', function () {
|
||||
$database = create_standalone_postgresql($this->environment->id, $this->destination, ['postgres_db' => 'appdb']);
|
||||
$database->runtime_environment_variables()->create(['key' => 'POSTGRES_DB', 'value' => 'customdb']);
|
||||
|
||||
StartPostgresql::run($database->fresh(), new Activity);
|
||||
|
||||
$postgresDbEntries = collect(databaseStartComposeEnvironment($this->executor, $database->uuid))
|
||||
->filter(fn (string $entry) => str($entry)->startsWith('POSTGRES_DB='))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($postgresDbEntries)->toBe(['POSTGRES_DB=customdb']);
|
||||
});
|
||||
|
||||
it('adds the password default when the user only sets a *_FILE variant', function (string $action, string $factory, array $data, string $userKey, string $expected) {
|
||||
$database = $factory($this->environment->id, $this->destination, $data);
|
||||
$database->runtime_environment_variables()->create(['key' => $userKey, 'value' => '/run/secrets/x']);
|
||||
|
||||
$action::run($database->fresh(), new Activity);
|
||||
|
||||
expect(databaseStartComposeEnvironment($this->executor, $database->uuid))
|
||||
->toContain("{$userKey}=/run/secrets/x")
|
||||
->toContain($expected);
|
||||
})->with([
|
||||
'mysql' => [StartMysql::class, 'create_standalone_mysql', ['mysql_root_password' => 'root-secret'], 'MYSQL_ROOT_PASSWORD_FILE', 'MYSQL_ROOT_PASSWORD=root-secret'],
|
||||
'mariadb' => [StartMariadb::class, 'create_standalone_mariadb', ['mariadb_root_password' => 'root-secret'], 'MARIADB_ROOT_PASSWORD_FILE', 'MARIADB_ROOT_PASSWORD=root-secret'],
|
||||
'mongodb' => [StartMongodb::class, 'create_standalone_mongodb', ['mongo_initdb_root_password' => 'root-secret'], 'MONGO_INITDB_ROOT_PASSWORD_FILE', 'MONGO_INITDB_ROOT_PASSWORD=root-secret'],
|
||||
'clickhouse' => [StartClickhouse::class, 'create_standalone_clickhouse', ['clickhouse_admin_password' => 'root-secret'], 'CLICKHOUSE_PASSWORD_FILE', 'CLICKHOUSE_PASSWORD=root-secret'],
|
||||
]);
|
||||
@@ -120,6 +120,23 @@ test('a cloudflare record is created and tracked as managed by coolify', functio
|
||||
&& $request->data()['comment'] === $record->ownershipComment());
|
||||
});
|
||||
|
||||
test('cloudflare records are created proxied only when the credential enables proxy mode', function (?array $metadata, bool $proxied) {
|
||||
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret', 'metadata' => $metadata]);
|
||||
$zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
|
||||
|
||||
Http::fake([
|
||||
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]),
|
||||
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]),
|
||||
]);
|
||||
|
||||
app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->data()['proxied'] === $proxied);
|
||||
})->with([
|
||||
'default' => [null, false],
|
||||
'enabled' => [['proxied_dns' => true], true],
|
||||
]);
|
||||
|
||||
test('queued dns configuration creates the record and broadcasts completion', function () {
|
||||
Event::fake([DnsRecordConfigurationFinished::class]);
|
||||
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
|
||||
|
||||
@@ -267,3 +267,43 @@ it('validateDNSEntry treats equivalent IPv6 notations as the same address', func
|
||||
|
||||
expect(validateDNSEntry('https://example.com', $server))->toBeTrue();
|
||||
});
|
||||
|
||||
it('passes bracketed IPv6 custom dns servers to the dns query', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
|
||||
['id' => 0],
|
||||
[
|
||||
'is_dns_validation_enabled' => true,
|
||||
'custom_dns_servers' => '2606:4700:4700::1111,[2001:db8::53],192.0.2.1',
|
||||
]
|
||||
));
|
||||
|
||||
$queriedServers = new ArrayObject;
|
||||
app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers) {
|
||||
$queriedServers->append($parameters['server']);
|
||||
|
||||
return new class('192.0.2.1') extends DNSQuery
|
||||
{
|
||||
public function query(string $question, string $typeName = DNSTypes::NAME_A): false
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasError(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$action = new class extends CheckDomainDns
|
||||
{
|
||||
protected function resolveWithSystemDns(string $host, string $type): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
$action->handle(['example' => 'https://example.com'], new Server(['ip' => '203.0.113.10']), '203.0.113.10');
|
||||
|
||||
expect($queriedServers->getArrayCopy())->toBe(['[2606:4700:4700::1111]', '[2001:db8::53]', '192.0.2.1']);
|
||||
});
|
||||
|
||||
@@ -174,6 +174,53 @@ describe('PATCH /api/v1/applications/{uuid}/envs/bulk', function () {
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('keeps one row per key and preview flag when the same key is sent more than once', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
|
||||
'data' => [
|
||||
['key' => 'DUPLICATED', 'value' => 'first'],
|
||||
['key' => 'DUPLICATED', 'value' => 'second'],
|
||||
['key' => 'PREVIEW_ONLY', 'value' => 'preview-b', 'is_preview' => true],
|
||||
['key' => 'SHARED', 'value' => 'production-a'],
|
||||
['key' => 'SHARED', 'value' => 'preview-a', 'is_preview' => true],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$rows = EnvironmentVariable::where('resourceable_type', Application::class)
|
||||
->where('resourceable_id', $application->id)
|
||||
->whereIn('key', ['DUPLICATED', 'PREVIEW_ONLY', 'SHARED'])
|
||||
->get();
|
||||
|
||||
$counts = $rows->countBy(fn ($env) => $env->key.'|'.($env->is_preview ? 'preview' : 'production'))->all();
|
||||
ksort($counts);
|
||||
|
||||
expect($counts)->toBe([
|
||||
'DUPLICATED|preview' => 1,
|
||||
'DUPLICATED|production' => 1,
|
||||
'PREVIEW_ONLY|preview' => 1,
|
||||
'SHARED|preview' => 1,
|
||||
'SHARED|production' => 1,
|
||||
]);
|
||||
|
||||
$valueOf = fn (string $key, bool $isPreview) => $rows
|
||||
->first(fn ($env) => $env->key === $key && $env->is_preview === $isPreview)
|
||||
->value;
|
||||
|
||||
expect($valueOf('DUPLICATED', false))->toBe('second')
|
||||
->and($valueOf('SHARED', false))->toBe('production-a')
|
||||
->and($valueOf('SHARED', true))->toBe('preview-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/services/{uuid}/envs/bulk', function () {
|
||||
|
||||
@@ -96,3 +96,22 @@ describe('GitLab source setup view', function () {
|
||||
->toBe('http://100.75.155.70:8000/webhooks/source/gitlab/redirect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitLab webhook endpoint options for IPv6 instance addresses', function () {
|
||||
test('brackets the public IPv6 address and keeps IPv4 unchanged', function (string $ipv6) {
|
||||
config(['app.port' => 8000]);
|
||||
|
||||
InstanceSettings::findOrFail(0)->update([
|
||||
'public_ipv4' => '203.0.113.10',
|
||||
'public_ipv6' => $ipv6,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSet('ipv4', 'http://203.0.113.10:8000')
|
||||
->assertSet('ipv6', 'http://[2a01:4f8::1]:8000');
|
||||
})->with([
|
||||
'raw' => '2a01:4f8::1',
|
||||
'bracketed' => '[2a01:4f8::1]',
|
||||
]);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user