Files
coolify/app/Models/StandaloneDragonfly.php
T
Andras Bacsai 86d84e21c0 perf(databases): load Redis-family passwords once for database lists
Connection URLs of KeyDB, Dragonfly and Redis queried their password
variables once per database, so the database list API ran extra queries
for every database.

- Read REDIS_PASSWORD and REDIS_USERNAME from the loaded relation
- Load the variables once per type in the database list and the
  environment details API
2026-10-06 06:59:22 +02:00

446 lines
14 KiB
PHP

<?php
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use App\Traits\HasSecretManager;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected $fillable = [
'uuid',
'name',
'description',
'dragonfly_password',
'is_log_drain_enabled',
'is_include_timestamps',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
'public_port_timeout',
'enable_ssl',
'custom_docker_run_options',
'destination_type',
'destination_id',
'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
* API controllers should call makeVisible([...]) for callers with the
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
'dragonfly_password',
'internal_db_url',
'external_db_url',
// Internal start-command state, not part of the API.
'legacy_password_quoting',
];
protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'dragonfly_password' => 'encrypted',
'legacy_password_quoting' => 'boolean',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
];
protected static function booted()
{
static::created(function ($database) {
LocalPersistentVolume::create([
'name' => 'dragonfly-data-'.$database->uuid,
'mount_path' => '/data',
'host_path' => null,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
]);
});
static::forceDeleting(function ($database) {
$database->persistentStorages()->delete();
$database->scheduledBackups()->delete();
$database->environment_variables()->delete();
$database->tags()->detach();
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->last_online_at = now();
}
if ($database->exists && $database->isDirty('dragonfly_password')) {
$database->legacy_password_quoting = false;
}
});
}
/**
* Get query builder for Dragonfly databases owned by current team.
* If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
*/
public static function ownedByCurrentTeam()
{
return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
/**
* Get all Dragonfly databases owned by current team (cached for request duration).
*/
public static function ownedByCurrentTeamCached()
{
return once(function () {
return StandaloneDragonfly::ownedByCurrentTeam()->get();
});
}
protected function serverStatus(): Attribute
{
return Attribute::make(
get: function () {
return $this->destination->server->isFunctional();
}
);
}
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
if ($oldConfigHash === $newConfigHash) {
return false;
} else {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
}
public function isRunning()
{
return (bool) str($this->status)->contains('running');
}
public function isExited()
{
return (bool) str($this->status)->startsWith('exited');
}
public function workdir()
{
return database_configuration_dir()."/{$this->uuid}";
}
public function deleteConfigurations()
{
$server = data_get($this, 'destination.server');
$workdir = $this->workdir();
if (str($workdir)->endsWith($this->uuid)) {
instant_remote_process(['rm -rf '.$this->workdir()], $server, false);
}
}
public function deleteVolumes()
{
$persistentStorages = $this->persistentStorages()->get() ?? collect();
if ($persistentStorages->count() === 0) {
return;
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
public function realStatus()
{
return $this->getRawOriginal('status');
}
public function status(): Attribute
{
return Attribute::make(
set: function ($value) {
if (str($value)->contains('(')) {
$status = str($value)->before('(')->trim()->value();
$health = str($value)->after('(')->before(')')->trim()->value() ?? 'unhealthy';
} elseif (str($value)->contains(':')) {
$status = str($value)->before(':')->trim()->value();
$health = str($value)->after(':')->trim()->value() ?? 'unhealthy';
} else {
$status = $value;
$health = 'unhealthy';
}
return "$status:$health";
},
get: function ($value) {
if (str($value)->contains('(')) {
$status = str($value)->before('(')->trim()->value();
$health = str($value)->after('(')->before(')')->trim()->value() ?? 'unhealthy';
} elseif (str($value)->contains(':')) {
$status = str($value)->before(':')->trim()->value();
$health = str($value)->after(':')->trim()->value() ?? 'unhealthy';
} else {
$status = $value;
$health = 'unhealthy';
}
return "$status:$health";
},
);
}
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function project()
{
return data_get($this, 'environment.project');
}
public function team()
{
return data_get($this, 'environment.project.team');
}
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link()
{
if (data_get($this, 'environment.project.uuid')) {
return route('project.database.configuration', [
'project_uuid' => data_get($this, 'environment.project.uuid'),
'environment_uuid' => data_get($this, 'environment.uuid'),
'database_uuid' => data_get($this, 'uuid'),
]);
}
return null;
}
public function isLogDrainEnabled()
{
return data_get($this, 'is_log_drain_enabled', false);
}
public function portsMappings(): Attribute
{
return Attribute::make(
set: fn ($value) => $value === '' ? null : $value,
);
}
public function portsMappingsArray(): Attribute
{
return Attribute::make(
get: fn () => is_null($this->ports_mappings)
? []
: explode(',', $this->ports_mappings),
);
}
public function databaseType(): Attribute
{
return new Attribute(
get: fn () => $this->type(),
);
}
public function type(): string
{
return 'standalone-dragonfly';
}
protected function internalDbUrl(): Attribute
{
return new Attribute(
get: function () {
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$port = $this->enable_ssl ? 6380 : 6379;
$encodedPass = rawurlencode($this->connectionPassword());
$url = "{$scheme}://:{$encodedPass}@{$this->uuid}:{$port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
}
return $url;
}
);
}
protected function externalDbUrl(): Attribute
{
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$encodedPass = rawurlencode($this->connectionPassword());
$url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
}
return $url;
}
return null;
}
);
}
public function environment()
{
return $this->belongsTo(Environment::class);
}
public function fileStorages()
{
return $this->morphMany(LocalFileVolume::class, 'resource');
}
public function destination()
{
return $this->morphTo();
}
public function runtime_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
/**
* Uses the loaded runtime_environment_variables relation when present, so lists that eager
* load it do not run a query per database.
*/
private function runtimeEnvironmentVariable(string $key): ?EnvironmentVariable
{
if ($this->relationLoaded('runtime_environment_variables')) {
return $this->runtime_environment_variables->firstWhere('key', $key);
}
return $this->runtime_environment_variables()->where('key', $key)->first();
}
/**
* The REDIS_PASSWORD variable that sets the server password instead of the stored password.
* Databases created before this release (legacy_password_quoting) keep their v4.3.23 server
* password, the stored one, unless the variable reads a remote secret.
*/
public function serverPasswordEnvironmentVariable(): ?EnvironmentVariable
{
$environmentVariable = $this->runtimeEnvironmentVariable('REDIS_PASSWORD');
if (! $environmentVariable) {
return null;
}
if ($this->legacy_password_quoting && ! $this->environmentVariableUsesSecretManager($environmentVariable)) {
return null;
}
return $environmentVariable;
}
/**
* The server password for connection URLs. A remote secret is not fetched here, so the URL
* shows its reference instead of a stale or wrong password.
*/
public function connectionPassword(): string
{
$environmentVariable = $this->serverPasswordEnvironmentVariable();
if ($environmentVariable) {
$password = $this->environmentVariableUsesSecretManager($environmentVariable)
? (string) $environmentVariable->value
: (string) $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
if ($password !== '') {
return $password;
}
}
return (string) $this->dragonfly_password;
}
public function persistentStorages()
{
return $this->morphMany(LocalPersistentVolume::class, 'resource');
}
public function scheduledBackups()
{
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function isBackupSolutionAvailable()
{
return false;
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
}