mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 16:52:45 -04:00
Require deploy or root token access when creating managed mounts outside resource directories or changing their file contents through the API. Synchronize KeyDB and Dragonfly passwords with local and shared REDIS_PASSWORD values while preserving secret manager resolution.
294 lines
13 KiB
PHP
294 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Actions\Database;
|
|
|
|
use App\Exceptions\DatabaseStartException;
|
|
use App\Helpers\SslHelper;
|
|
use App\Models\SslCertificate;
|
|
use App\Models\StandaloneDragonfly;
|
|
use App\Traits\ExecutesDatabaseStartCommands;
|
|
use Lorisleiva\Actions\Concerns\AsAction;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
class StartDragonfly
|
|
{
|
|
use AsAction, ExecutesDatabaseStartCommands;
|
|
|
|
public StandaloneDragonfly $database;
|
|
|
|
public array $commands = [];
|
|
|
|
public string $configuration_dir;
|
|
|
|
private ?SslCertificate $ssl_certificate = null;
|
|
|
|
private string $resolvedRedisPassword;
|
|
|
|
private bool $redisPasswordFromSecretManager = false;
|
|
|
|
public function handle(StandaloneDragonfly $database, ?Activity $activity = null)
|
|
{
|
|
$this->database = $database;
|
|
|
|
$container_name = $this->database->uuid;
|
|
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
|
|
|
$this->commands = [
|
|
"echo 'Starting database.'",
|
|
"echo 'Creating directories.'",
|
|
"mkdir -p $this->configuration_dir",
|
|
"echo 'Directories created successfully.'",
|
|
];
|
|
|
|
if (! $this->database->enable_ssl) {
|
|
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
|
|
$this->database->sslCertificates()->delete();
|
|
$this->database->fileStorages()
|
|
->where('resource_type', $this->database->getMorphClass())
|
|
->where('resource_id', $this->database->id)
|
|
->get()
|
|
->filter(function ($storage) {
|
|
return in_array($storage->mount_path, [
|
|
'/etc/dragonfly/certs/server.crt',
|
|
'/etc/dragonfly/certs/server.key',
|
|
]);
|
|
})
|
|
->each(function ($storage) {
|
|
$storage->delete();
|
|
});
|
|
} else {
|
|
$this->commands[] = "echo 'Setting up SSL for this database.'";
|
|
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
|
|
|
|
$server = $this->database->destination->server;
|
|
$caCert = $server->ensureCaCertificate() ?? throw DatabaseStartException::missingCaCertificate();
|
|
array_push($this->commands, ...SslHelper::caCertificateFileCommands($caCert->ssl_certificate));
|
|
|
|
$this->ssl_certificate = $this->database->sslCertificates()->first();
|
|
|
|
if (! $this->ssl_certificate) {
|
|
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
|
|
$this->ssl_certificate = SslHelper::generateSslCertificate(
|
|
commonName: $this->database->uuid,
|
|
resourceType: $this->database->getMorphClass(),
|
|
resourceId: $this->database->id,
|
|
serverId: $server->id,
|
|
caCert: $caCert->ssl_certificate,
|
|
caKey: $caCert->ssl_private_key,
|
|
configurationDir: $this->configuration_dir,
|
|
mountPath: '/etc/dragonfly/certs',
|
|
);
|
|
}
|
|
}
|
|
|
|
$container_name = $this->database->uuid;
|
|
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
|
|
|
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
|
$persistent_file_volumes = $this->database->fileStorages()->get();
|
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
|
$environment_variables = $this->generate_environment_variables();
|
|
$startCommand = $this->buildStartCommand();
|
|
|
|
$docker_compose = [
|
|
'services' => [
|
|
$container_name => [
|
|
'image' => $this->database->image,
|
|
'command' => $startCommand,
|
|
'container_name' => $container_name,
|
|
'environment' => $environment_variables,
|
|
'restart' => RESTART_MODE,
|
|
'networks' => [
|
|
$this->database->destination->network,
|
|
],
|
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
|
'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping',
|
|
]),
|
|
'mem_limit' => $this->database->limits_memory,
|
|
'memswap_limit' => $this->database->limits_memory_swap,
|
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
|
'mem_reservation' => $this->database->limits_memory_reservation,
|
|
'cpus' => (float) $this->database->limits_cpus,
|
|
'cpu_shares' => $this->database->limits_cpu_shares,
|
|
],
|
|
],
|
|
'networks' => [
|
|
$this->database->destination->network => [
|
|
'external' => true,
|
|
'name' => $this->database->destination->network,
|
|
'attachable' => true,
|
|
],
|
|
],
|
|
];
|
|
|
|
if (! is_null($this->database->limits_cpuset)) {
|
|
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
|
|
}
|
|
|
|
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
|
|
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
|
|
}
|
|
|
|
if (count($this->database->ports_mappings_array) > 0) {
|
|
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
|
|
}
|
|
|
|
$docker_compose['services'][$container_name]['volumes'] ??= [];
|
|
|
|
if (count($persistent_storages) > 0) {
|
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
|
$docker_compose['services'][$container_name]['volumes'],
|
|
$persistent_storages
|
|
);
|
|
}
|
|
|
|
if (count($persistent_file_volumes) > 0) {
|
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
|
$docker_compose['services'][$container_name]['volumes'],
|
|
$persistent_file_volumes->map(function ($item) {
|
|
return "$item->fs_path:$item->mount_path";
|
|
})->toArray()
|
|
);
|
|
}
|
|
|
|
if (count($volume_names) > 0) {
|
|
$docker_compose['volumes'] = $volume_names;
|
|
}
|
|
|
|
if ($this->database->enable_ssl) {
|
|
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
|
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
|
[
|
|
[
|
|
'type' => 'bind',
|
|
'source' => '/data/coolify/ssl/coolify-ca.crt',
|
|
'target' => '/etc/dragonfly/certs/coolify-ca.crt',
|
|
'read_only' => true,
|
|
],
|
|
]
|
|
);
|
|
}
|
|
|
|
// Add custom docker run options
|
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
|
|
|
if (! $this->database->isHealthcheckEnabled()) {
|
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
|
}
|
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
|
$docker_compose_base64 = base64_encode($docker_compose);
|
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
|
$readme = generate_readme_file($this->database->name, now());
|
|
$this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
|
|
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
|
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
|
if ($this->database->enable_ssl) {
|
|
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
|
}
|
|
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
|
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
|
$this->commands[] = "echo 'Database started.'";
|
|
|
|
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
|
}
|
|
|
|
private function buildStartCommand(): string
|
|
{
|
|
$passwordArgument = $this->requirePassArgument();
|
|
$command = "dragonfly --requirepass {$passwordArgument}";
|
|
|
|
if ($this->database->enable_ssl) {
|
|
$sslArgs = [
|
|
'--tls',
|
|
'--tls_cert_file /etc/dragonfly/certs/server.crt',
|
|
'--tls_key_file /etc/dragonfly/certs/server.key',
|
|
'--tls_ca_cert_file /etc/dragonfly/certs/coolify-ca.crt',
|
|
];
|
|
$command .= ' '.implode(' ', $sslArgs);
|
|
}
|
|
|
|
return $command;
|
|
}
|
|
|
|
private function generate_local_persistent_volumes()
|
|
{
|
|
$local_persistent_volumes = [];
|
|
foreach ($this->database->persistentStorages as $persistentStorage) {
|
|
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
|
|
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
|
|
} else {
|
|
$volume_name = $persistentStorage->name;
|
|
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
|
|
}
|
|
}
|
|
|
|
return $local_persistent_volumes;
|
|
}
|
|
|
|
private function generate_local_persistent_volumes_only_volume_names()
|
|
{
|
|
$local_persistent_volumes_names = [];
|
|
foreach ($this->database->persistentStorages as $persistentStorage) {
|
|
if ($persistentStorage->host_path) {
|
|
continue;
|
|
}
|
|
$name = $persistentStorage->name;
|
|
$local_persistent_volumes_names[$name] = [
|
|
'name' => $name,
|
|
'external' => false,
|
|
];
|
|
}
|
|
|
|
return $local_persistent_volumes_names;
|
|
}
|
|
|
|
private function generate_environment_variables()
|
|
{
|
|
$environment_variables = collect();
|
|
$this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
|
|
foreach ($this->database->runtime_environment_variables as $env) {
|
|
$usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
|
|
if ($env->key === 'REDIS_PASSWORD' && ! $env->is_shared && ! $usesSecretManager) {
|
|
$env->update(['value' => $this->database->dragonfly_password]);
|
|
}
|
|
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
|
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
|
// Credentials below are placed directly in the compose file (healthcheck, command).
|
|
$composeFileValue = $this->database->formatComposeFileValue($env, $rawValue);
|
|
$environment_variables->push($env->key.'='.$resolvedValue);
|
|
if ($env->key === 'REDIS_PASSWORD') {
|
|
if ($env->is_shared && ! $usesSecretManager) {
|
|
$this->database->update(['dragonfly_password' => $rawValue]);
|
|
}
|
|
$this->resolvedRedisPassword = $composeFileValue;
|
|
$this->redisPasswordFromSecretManager = $usesSecretManager;
|
|
}
|
|
}
|
|
|
|
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
|
$environment_variables->push("REDIS_PASSWORD={$this->database->dragonfly_password}");
|
|
}
|
|
|
|
return $environment_variables->all();
|
|
}
|
|
|
|
/**
|
|
* The password argument of the start command. Docker Compose splits the command like a shell, so the
|
|
* password is quoted. Databases created before this release keep their unquoted v4.3.23 argument when
|
|
* quoting would change it (quotes, backslashes, whitespace, ; & | < >): Compose splits or cuts the old
|
|
* command there, so quoting now would change the password or options and can lose data.
|
|
*/
|
|
private function requirePassArgument(): string
|
|
{
|
|
$keepsUnquotedPassword = $this->database->legacy_password_quoting
|
|
&& ! $this->redisPasswordFromSecretManager
|
|
&& strpbrk($this->resolvedRedisPassword, "\\'\";&|<> \t\r\n") !== false;
|
|
|
|
return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword);
|
|
}
|
|
}
|