diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 077e95080f..f9e92e08f1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -156,12 +156,13 @@ class StartClickhouse $this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user; $this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password; foreach ($this->database->runtime_environment_variables as $env) { - $resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'CLICKHOUSE_USER') { - $this->resolvedClickhouseUser = $resolvedValue; + $this->resolvedClickhouseUser = $rawValue; } elseif ($env->key === 'CLICKHOUSE_PASSWORD') { - $this->resolvedClickhousePassword = $resolvedValue; + $this->resolvedClickhousePassword = $rawValue; } } diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 86286a154b..3487bc9a42 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -46,11 +46,16 @@ class StartDatabase ->event(ActivityTypes::INLINE->value) ->log('[]'); + if ($activity === null) { + return 'Database start could not be queued because activity logging is disabled.'; + } + DatabaseStartJob::dispatch( $database->getMorphClass(), (int) $database->getKey(), (int) $database->team()->id, (int) $activity->getKey(), + auth()->id(), ); if ($database->is_public && $database->public_port) { diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index b76e90ce49..078d557f57 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -205,7 +205,8 @@ class StartDragonfly private function buildStartCommand(): string { - $command = "dragonfly --requirepass {$this->resolvedRedisPassword}"; + $escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword); + $command = "dragonfly --requirepass {$escapedRedisPassword}"; if ($this->database->enable_ssl) { $sslArgs = [ @@ -257,10 +258,11 @@ class StartDragonfly $environment_variables = collect(); $this->resolvedRedisPassword = (string) $this->database->dragonfly_password; foreach ($this->database->runtime_environment_variables as $env) { - $resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'REDIS_PASSWORD') { - $this->resolvedRedisPassword = $resolvedValue; + $this->resolvedRedisPassword = $rawValue; } } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index a355617ed7..3b9cba28f4 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -258,10 +258,11 @@ class StartKeydb $environment_variables = collect(); $this->resolvedRedisPassword = (string) $this->database->keydb_password; foreach ($this->database->runtime_environment_variables as $env) { - $resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'REDIS_PASSWORD') { - $this->resolvedRedisPassword = $resolvedValue; + $this->resolvedRedisPassword = $rawValue; } } @@ -289,6 +290,7 @@ class StartKeydb { $hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf); $keydbConfPath = '/etc/keydb/keydb.conf'; + $escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword); if ($hasKeydbConf) { $confContent = $this->database->keydb_conf; @@ -297,10 +299,10 @@ class StartKeydb if ($hasRequirePass) { $command = "keydb-server $keydbConfPath"; } else { - $command = "keydb-server $keydbConfPath --requirepass {$this->resolvedRedisPassword}"; + $command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}"; } } else { - $command = "keydb-server --requirepass {$this->resolvedRedisPassword} --appendonly yes"; + $command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes"; } if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index f6ea641b4b..ff338aa99f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -315,14 +315,15 @@ class StartMongodb $this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password; $this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database; foreach ($this->database->runtime_environment_variables as $env) { - $resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') { - $this->resolvedMongoUsername = $resolvedValue; + $this->resolvedMongoUsername = $rawValue; } elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') { - $this->resolvedMongoPassword = $resolvedValue; + $this->resolvedMongoPassword = $rawValue; } elseif ($env->key === 'MONGO_INITDB_DATABASE') { - $this->resolvedMongoDatabase = $resolvedValue; + $this->resolvedMongoDatabase = $rawValue; } } diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 0615f15ca9..cff8d0b363 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -262,10 +262,11 @@ class StartMysql $environment_variables = collect(); $this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password; foreach ($this->database->runtime_environment_variables as $env) { - $resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'MYSQL_ROOT_PASSWORD') { - $this->resolvedMysqlRootPassword = $resolvedValue; + $this->resolvedMysqlRootPassword = $rawValue; } } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 6e62b29707..f9dd7a3c4f 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -274,12 +274,13 @@ class StartPostgresql $this->resolvedPostgresUser = (string) $this->database->postgres_user; $this->resolvedPostgresDatabase = (string) $this->database->postgres_db; foreach ($this->database->runtime_environment_variables as $env) { - $resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'POSTGRES_USER') { - $this->resolvedPostgresUser = $resolvedValue; + $this->resolvedPostgresUser = $rawValue; } elseif ($env->key === 'POSTGRES_DB') { - $this->resolvedPostgresDatabase = $resolvedValue; + $this->resolvedPostgresDatabase = $rawValue; } } diff --git a/app/Jobs/DatabaseStartJob.php b/app/Jobs/DatabaseStartJob.php index da93a2dd21..e21ee38c61 100644 --- a/app/Jobs/DatabaseStartJob.php +++ b/app/Jobs/DatabaseStartJob.php @@ -42,6 +42,7 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue public int $databaseId, public int $teamId, public int $activityId, + public ?int $userId, ) { $this->onQueue(deployment_queue()); } @@ -52,34 +53,36 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue abort_unless((int) $database->team()->id === $this->teamId, 403); $activity = Activity::query()->findOrFail($this->activityId); - try { - match ($database->getMorphClass()) { - StandalonePostgresql::class => StartPostgresql::run($database, $activity), - StandaloneRedis::class => StartRedis::run($database, $activity), - StandaloneMongodb::class => StartMongodb::run($database, $activity), - StandaloneMysql::class => StartMysql::run($database, $activity), - StandaloneMariadb::class => StartMariadb::run($database, $activity), - StandaloneKeydb::class => StartKeydb::run($database, $activity), - StandaloneDragonfly::class => StartDragonfly::run($database, $activity), - StandaloneClickhouse::class => StartClickhouse::run($database, $activity), - }; - } finally { - event(new DatabaseStatusChanged($database)); - } + match ($database->getMorphClass()) { + StandalonePostgresql::class => StartPostgresql::run($database, $activity), + StandaloneRedis::class => StartRedis::run($database, $activity), + StandaloneMongodb::class => StartMongodb::run($database, $activity), + StandaloneMysql::class => StartMysql::run($database, $activity), + StandaloneMariadb::class => StartMariadb::run($database, $activity), + StandaloneKeydb::class => StartKeydb::run($database, $activity), + StandaloneDragonfly::class => StartDragonfly::run($database, $activity), + StandaloneClickhouse::class => StartClickhouse::run($database, $activity), + }; + + event(new DatabaseStatusChanged($this->userId)); } public function failed(?Throwable $exception): void { - $activity = Activity::query()->find($this->activityId); - if (! $activity) { - return; - } + try { + $activity = Activity::query()->find($this->activityId); + if (! $activity) { + return; + } - $activity->properties = $activity->properties->merge([ - 'status' => ProcessStatus::ERROR->value, - 'error' => 'Database start failed.', - 'failed_at' => now()->toIso8601String(), - ]); - $activity->save(); + $activity->properties = $activity->properties->merge([ + 'status' => ProcessStatus::ERROR->value, + 'error' => 'Database start failed.', + 'failed_at' => now()->toIso8601String(), + ]); + $activity->save(); + } finally { + event(new DatabaseStatusChanged($this->userId)); + } } } diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php index 7ade50e4ba..8b3e50b7bd 100644 --- a/app/Traits/HasSecretManager.php +++ b/app/Traits/HasSecretManager.php @@ -27,6 +27,11 @@ trait HasSecretManager { $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + return $this->formatEnvironmentVariableValue($environmentVariable, $value); + } + + public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string + { if ($value === null) { return null; } diff --git a/tests/Feature/DatabaseStartActivityLoggingTest.php b/tests/Feature/DatabaseStartActivityLoggingTest.php new file mode 100644 index 0000000000..ff8f0e8ec8 --- /dev/null +++ b/tests/Feature/DatabaseStartActivityLoggingTest.php @@ -0,0 +1,34 @@ +set('activitylog.enabled', false); + app(ActivityLogStatus::class)->disable(); + Bus::fake(); + + $server = new Server(['ip' => '192.0.2.1']); + $server->setRelation('settings', new ServerSetting([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])); + + $destination = new StandaloneDocker; + $destination->setRelation('server', $server); + + $database = new StandaloneRedis; + $database->setRelation('destination', $destination); + + $result = (new StartDatabase)->handle($database); + + expect($result)->toBe('Database start could not be queued because activity logging is disabled.'); + Bus::assertNotDispatched(DatabaseStartJob::class); +}); diff --git a/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php b/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php index 364d544904..7ee295e34f 100644 --- a/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php +++ b/tests/Unit/DatabaseStartActionResolvedCredentialsTest.php @@ -64,3 +64,20 @@ it('queues database starts with identifiers instead of generated commands', func ->not->toContain('StartPostgresql::run(') ->not->toContain('StartRedis::run('); }); + +it('keeps raw secret values separate from compose environment formatting', function (string $action, array $rawAssignments) { + $source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php"); + + expect($source) + ->toContain('$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);') + ->toContain('$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);') + ->toContain('$environment_variables->push($env->key.\'=\'.$resolvedValue);') + ->toContain(...$rawAssignments); +})->with([ + 'clickhouse' => ['StartClickhouse', ['$this->resolvedClickhouseUser = $rawValue;', '$this->resolvedClickhousePassword = $rawValue;']], + 'dragonfly' => ['StartDragonfly', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']], + 'keydb' => ['StartKeydb', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']], + 'mongodb' => ['StartMongodb', ['$this->resolvedMongoUsername = $rawValue;', '$this->resolvedMongoPassword = $rawValue;', '$this->resolvedMongoDatabase = $rawValue;', 'json_encode($this->resolvedMongoPassword']], + 'mysql' => ['StartMysql', ['$this->resolvedMysqlRootPassword = $rawValue;']], + 'postgresql' => ['StartPostgresql', ['$this->resolvedPostgresUser = $rawValue;', '$this->resolvedPostgresDatabase = $rawValue;']], +]); diff --git a/tests/Unit/DatabaseStartJobTest.php b/tests/Unit/DatabaseStartJobTest.php new file mode 100644 index 0000000000..e6333ea769 --- /dev/null +++ b/tests/Unit/DatabaseStartJobTest.php @@ -0,0 +1,36 @@ +failed(new RuntimeException('Database start failed.')); + + Event::assertDispatched( + DatabaseStatusChanged::class, + fn (DatabaseStatusChanged $event): bool => $event->userId === 42, + ); +}); + +it('targets normal database start status changes to the initiating user', function () { + $source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseStartJob.php'); + + expect($source) + ->toContain('event(new DatabaseStatusChanged($this->userId));') + ->not->toContain('event(new DatabaseStatusChanged($database));'); +});