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:
Andras Bacsai
2026-10-09 14:53:05 +02:00
parent f87f80ae96
commit 8e0f4d0dfa
24 changed files with 614 additions and 50 deletions
+5 -3
View File
@@ -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}");
}
+12 -1
View File
@@ -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) {
+3 -1
View File
@@ -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}");
}
+3 -1
View File
@@ -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}");
}
+6 -4
View File
@@ -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}");
}
+5 -3
View File
@@ -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}");
}
+6 -4
View File
@@ -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}");
}
+6 -4
View File
@@ -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 -4
View File
@@ -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) {
+3 -1
View File
@@ -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) {
+3 -18
View File
@@ -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);
}
}
+9 -1
View File
@@ -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([
@@ -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
{
//
}
};
@@ -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
{
//
}
};
+15
View File
@@ -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;']);
});