From 8e0f4d0dfa82d085d8f24b21820e234d7e30a278 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:53:05 +0200 Subject: [PATCH] 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 --- app/Actions/Database/StartClickhouse.php | 8 +- app/Actions/Database/StartDatabaseProxy.php | 13 ++- app/Actions/Database/StartDragonfly.php | 4 +- app/Actions/Database/StartKeydb.php | 4 +- app/Actions/Database/StartMariadb.php | 10 +- app/Actions/Database/StartMongodb.php | 8 +- app/Actions/Database/StartMysql.php | 10 +- app/Actions/Database/StartPostgresql.php | 10 +- app/Actions/Database/StartRedis.php | 6 +- app/Actions/Database/StartSqlite.php | 4 +- .../Api/ApplicationsController.php | 4 +- app/Models/StandaloneRedis.php | 21 +--- app/Traits/ExecutesDatabaseStartCommands.php | 11 ++ bootstrap/helpers/applications.php | 10 +- ...e_redis_username_environment_variables.php | 42 +++++++ ...cks_node_version_environment_variables.php | 42 +++++++ tests/Feature/Api/LifecycleApisTest.php | 15 +++ .../RedisFamilyDatabaseListQueryCountTest.php | 6 +- ...oneApplicationEnvironmentVariablesTest.php | 81 +++++++++++++ .../Feature/DatabaseStartContentFilesTest.php | 99 ++++++++++++++++ ...seStartDefaultEnvironmentVariablesTest.php | 108 ++++++++++++++++++ .../EnvironmentVariableBulkCommentApiTest.php | 47 ++++++++ .../RedisUsernameEnvironmentVariableTest.php | 92 +++++++++++++++ .../Service/StartDatabaseProxyTest.php | 9 ++ 24 files changed, 614 insertions(+), 50 deletions(-) create mode 100644 database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php create mode 100644 database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php create mode 100644 tests/Feature/CloneApplicationEnvironmentVariablesTest.php create mode 100644 tests/Feature/DatabaseStartContentFilesTest.php create mode 100644 tests/Feature/DatabaseStartDefaultEnvironmentVariablesTest.php create mode 100644 tests/Feature/RedisUsernameEnvironmentVariableTest.php diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index f3831125a1..d053142d2c 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -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}"); } diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 8622b9be0c..f26b8cbb25 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -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 = <<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}"); } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 770d055001..fa86576d5e 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -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}"); } diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 6dd7842fed..bf5739a86b 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -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}"); } diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 61f252da17..0afb0c4a6f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -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}"); } diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index a5c9f8515e..ea723aada7 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -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}"); } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 0f1da0e481..c0949e51b8 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -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}"); } diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 7fc89b25ce..c12381aca8 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -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) { diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index 377a680594..9289cdf5a0 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -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}"); } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index e343176ec2..d2918e947c 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 53f54ebe28..abc1d4c0c6 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -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'; } ); } diff --git a/app/Traits/ExecutesDatabaseStartCommands.php b/app/Traits/ExecutesDatabaseStartCommands.php index fca00cafa0..d61cfb3146 100644 --- a/app/Traits/ExecutesDatabaseStartCommands.php +++ b/app/Traits/ExecutesDatabaseStartCommands.php @@ -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 $environmentVariables + */ + private function hasEnvironmentVariable(Collection $environmentVariables, string $key): bool + { + return $environmentVariables->contains(fn ($variable) => str($variable)->before('=')->value() === $key); + } } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 2f57eb8a9b..9e1f8867c5 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -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([ diff --git a/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php b/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php new file mode 100644 index 0000000000..6b367cc59c --- /dev/null +++ b/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php @@ -0,0 +1,42 @@ +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 + { + // + } +}; diff --git a/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php b/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php new file mode 100644 index 0000000000..d82fa896fb --- /dev/null +++ b/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php @@ -0,0 +1,42 @@ +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 + { + // + } +}; diff --git a/tests/Feature/Api/LifecycleApisTest.php b/tests/Feature/Api/LifecycleApisTest.php index d89dcf414d..f761d0d218 100644 --- a/tests/Feature/Api/LifecycleApisTest.php +++ b/tests/Feature/Api/LifecycleApisTest.php @@ -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(); diff --git a/tests/Feature/Api/RedisFamilyDatabaseListQueryCountTest.php b/tests/Feature/Api/RedisFamilyDatabaseListQueryCountTest.php index bea7b1de5b..0485690e4a 100644 --- a/tests/Feature/Api/RedisFamilyDatabaseListQueryCountTest.php +++ b/tests/Feature/Api/RedisFamilyDatabaseListQueryCountTest.php @@ -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); }); diff --git a/tests/Feature/CloneApplicationEnvironmentVariablesTest.php b/tests/Feature/CloneApplicationEnvironmentVariablesTest.php new file mode 100644 index 0000000000..d57b1420c0 --- /dev/null +++ b/tests/Feature/CloneApplicationEnvironmentVariablesTest.php @@ -0,0 +1,81 @@ +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); +}); diff --git a/tests/Feature/DatabaseStartContentFilesTest.php b/tests/Feature/DatabaseStartContentFilesTest.php new file mode 100644 index 0000000000..644e129ccd --- /dev/null +++ b/tests/Feature/DatabaseStartContentFilesTest.php @@ -0,0 +1,99 @@ + 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')); +}); diff --git a/tests/Feature/DatabaseStartDefaultEnvironmentVariablesTest.php b/tests/Feature/DatabaseStartDefaultEnvironmentVariablesTest.php new file mode 100644 index 0000000000..33294eaf80 --- /dev/null +++ b/tests/Feature/DatabaseStartDefaultEnvironmentVariablesTest.php @@ -0,0 +1,108 @@ + 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 + */ +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'], +]); diff --git a/tests/Feature/EnvironmentVariableBulkCommentApiTest.php b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php index 20fc96ba35..c3e83ac188 100644 --- a/tests/Feature/EnvironmentVariableBulkCommentApiTest.php +++ b/tests/Feature/EnvironmentVariableBulkCommentApiTest.php @@ -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 () { diff --git a/tests/Feature/RedisUsernameEnvironmentVariableTest.php b/tests/Feature/RedisUsernameEnvironmentVariableTest.php new file mode 100644 index 0000000000..896792d949 --- /dev/null +++ b/tests/Feature/RedisUsernameEnvironmentVariableTest.php @@ -0,0 +1,92 @@ + 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); +}); diff --git a/tests/Feature/Service/StartDatabaseProxyTest.php b/tests/Feature/Service/StartDatabaseProxyTest.php index 6dda6f3f40..2de8b083de 100644 --- a/tests/Feature/Service/StartDatabaseProxyTest.php +++ b/tests/Feature/Service/StartDatabaseProxyTest.php @@ -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;']); +});