fix(database): preserve credentials and activity context on start

Keep raw secret values for database credentials, escape Redis passwords in startup commands, and pass user context through queued database starts.
This commit is contained in:
Andras Bacsai
2026-08-23 23:03:06 +02:00
parent f49c330d9d
commit 6d716e54d7
12 changed files with 151 additions and 43 deletions
+4 -3
View File
@@ -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;
}
}
+5
View File
@@ -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) {
+5 -3
View File
@@ -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;
}
}
+6 -4
View File
@@ -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) {
+5 -4
View File
@@ -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;
}
}
+3 -2
View File
@@ -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;
}
}
+4 -3
View File
@@ -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;
}
}
+27 -24
View File
@@ -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));
}
}
}
+5
View File
@@ -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;
}
@@ -0,0 +1,34 @@
<?php
use App\Actions\Database\StartDatabase;
use App\Jobs\DatabaseStartJob;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\StandaloneDocker;
use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Bus;
use Spatie\Activitylog\ActivityLogStatus;
it('returns an actionable error when database start activity logging is disabled', function () {
config()->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);
});
@@ -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;']],
]);
+36
View File
@@ -0,0 +1,36 @@
<?php
use App\Events\DatabaseStatusChanged;
use App\Jobs\DatabaseStartJob;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('broadcasts failed database starts to the initiating user even when the activity is missing', function () {
Event::fake([DatabaseStatusChanged::class]);
$job = new DatabaseStartJob(
databaseClass: 'MissingDatabase',
databaseId: 123,
teamId: 456,
activityId: 789,
userId: 42,
);
$job->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));');
});