mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(database): prevent duplicate environment variables on start and clone
- Stop the Redis username accessor from creating REDIS_USERNAME on read - Keep source values when cloning applications (no duplicate NIXPACKS_NODE_VERSION) - Match exact keys when adding default database environment variables - Write content files before database containers start - Resolve the database proxy upstream through Docker DNS on each connection - Remove existing duplicate REDIS_USERNAME and NIXPACKS_NODE_VERSION rows
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ class StartDatabaseProxy
|
||||
$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;
|
||||
@@ -70,9 +71,10 @@ class StartDatabaseProxy
|
||||
worker_connections 1024;
|
||||
}
|
||||
stream {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
server {
|
||||
$listenConfig
|
||||
proxy_pass $containerName:$internalPort;
|
||||
$upstreamConfig
|
||||
$timeoutConfig
|
||||
}
|
||||
}
|
||||
@@ -187,6 +189,15 @@ class StartDatabaseProxy
|
||||
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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -4057,7 +4057,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');
|
||||
|
||||
@@ -4096,7 +4096,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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -473,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';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
+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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'],
|
||||
]);
|
||||
@@ -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 () {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
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();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
||||
// Audit logging runs only for an authenticated user, and it loads the database while REDIS_PASSWORD is created.
|
||||
$this->actingAs($user);
|
||||
|
||||
$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);
|
||||
});
|
||||
|
||||
it('creates one REDIS_USERNAME variable and starts with unique environment entries', function () {
|
||||
$database = create_standalone_redis($this->environment->id, $this->destination, ['image' => 'redis:7.2']);
|
||||
|
||||
expect($database->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(1);
|
||||
|
||||
StartRedis::run($database->fresh(), new Activity);
|
||||
|
||||
$environment = null;
|
||||
foreach ($this->executor->commands as $command) {
|
||||
if (preg_match("#^echo '([^']+)' \| base64 -d \| tee \S+/docker-compose\.yml#", $command, $matches)) {
|
||||
$environment = Yaml::parse(base64_decode($matches[1]))['services'][$database->uuid]['environment'];
|
||||
}
|
||||
}
|
||||
|
||||
expect($environment)->toHaveCount(2)
|
||||
->and($environment)->toContain('REDIS_USERNAME=default')
|
||||
->and(array_unique($environment))->toBe($environment);
|
||||
});
|
||||
|
||||
it('removes duplicate REDIS_USERNAME variables and keeps the most recently changed one', function () {
|
||||
$database = create_standalone_redis($this->environment->id, $this->destination);
|
||||
$other = create_standalone_redis($this->environment->id, $this->destination);
|
||||
$kept = EnvironmentVariable::query()->create([
|
||||
'key' => 'REDIS_USERNAME',
|
||||
'value' => 'admin',
|
||||
'resourceable_type' => StandaloneRedis::class,
|
||||
'resourceable_id' => $database->id,
|
||||
]);
|
||||
$kept->forceFill(['updated_at' => now()->addMinute()])->saveQuietly();
|
||||
|
||||
$migration = require database_path('migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php');
|
||||
$migration->up();
|
||||
|
||||
expect($database->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->pluck('id')->all())->toBe([$kept->id])
|
||||
->and($database->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->count())->toBe(1)
|
||||
->and($other->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(1);
|
||||
});
|
||||
@@ -82,3 +82,12 @@ test('buildListenConfig adds an IPv6 listener only when the network has IPv6 ena
|
||||
'IPv4 only network' => [false, ['listen 28197;']],
|
||||
'IPv6 enabled network' => [true, ['listen 28197;', 'listen [::]:28197;']],
|
||||
]);
|
||||
|
||||
test('buildUpstreamConfig resolves the database host at runtime', function () {
|
||||
$action = new StartDatabaseProxy;
|
||||
$method = new ReflectionMethod($action, 'buildUpstreamConfig');
|
||||
|
||||
$lines = array_map('trim', explode("\n", $method->invoke($action, 'db-uuid', 27017)));
|
||||
|
||||
expect($lines)->toBe(['set $upstream db-uuid:27017;', 'proxy_pass $upstream;']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user