mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;']],
|
||||
]);
|
||||
|
||||
@@ -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));');
|
||||
});
|
||||
Reference in New Issue
Block a user