From d423223d38ebe200427a235cdb213ff9bc78941a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 31 May 2026 21:50:10 +0200 Subject: [PATCH] feat(database): configure standalone health checks Add configurable health check settings for standalone databases and apply them to generated Docker Compose services. Allow disabling health checks and cover the behavior with feature tests. --- app/Actions/Database/StartClickhouse.php | 11 ++- app/Actions/Database/StartDragonfly.php | 11 ++- app/Actions/Database/StartKeydb.php | 11 ++- app/Actions/Database/StartMariadb.php | 11 ++- app/Actions/Database/StartMongodb.php | 11 ++- app/Actions/Database/StartMysql.php | 11 ++- app/Actions/Database/StartPostgresql.php | 11 ++- app/Actions/Database/StartRedis.php | 11 ++- .../Controllers/Api/DatabasesController.php | 17 +++- app/Livewire/Project/Database/Health.php | 81 +++++++++++++++++++ app/Models/StandaloneClickhouse.php | 14 +++- app/Models/StandaloneDragonfly.php | 14 +++- app/Models/StandaloneKeydb.php | 14 +++- app/Models/StandaloneMariadb.php | 14 +++- app/Models/StandaloneMongodb.php | 14 +++- app/Models/StandaloneMysql.php | 14 +++- app/Models/StandalonePostgresql.php | 14 +++- app/Models/StandaloneRedis.php | 14 +++- app/Traits/HasDatabaseHealthCheck.php | 34 ++++++++ ...d_health_check_to_standalone_databases.php | 47 +++++++++++ openapi.json | 20 +++++ openapi.yaml | 15 ++++ .../project/database/configuration.blade.php | 4 + .../project/database/health.blade.php | 26 ++++++ routes/web.php | 1 + tests/Feature/DatabaseHealthCheckTest.php | 45 +++++++++++ 26 files changed, 448 insertions(+), 42 deletions(-) create mode 100644 app/Livewire/Project/Database/Health.php create mode 100644 app/Traits/HasDatabaseHealthCheck.php create mode 100644 database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php create mode 100644 resources/views/livewire/project/database/health.blade.php create mode 100644 tests/Feature/DatabaseHealthCheckTest.php diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 30cae71f1..1128b8f8f 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -52,10 +52,10 @@ class StartClickhouse 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', + 'interval' => "{$this->database->health_check_interval}s", + 'timeout' => "{$this->database->health_check_timeout}s", + 'retries' => $this->database->health_check_retries, + 'start_period' => "{$this->database->health_check_start_period}s", ], 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -98,6 +98,9 @@ class StartClickhouse $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"; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index addc30be4..530ba7d23 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -108,10 +108,10 @@ class StartDragonfly 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ 'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', + 'interval' => "{$this->database->health_check_interval}s", + 'timeout' => "{$this->database->health_check_timeout}s", + 'retries' => $this->database->health_check_retries, + 'start_period' => "{$this->database->health_check_start_period}s", ], 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -182,6 +182,9 @@ class StartDragonfly $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"; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index e59d6f697..e9acd0b3c 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -110,10 +110,10 @@ class StartKeydb 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ 'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', + 'interval' => "{$this->database->health_check_interval}s", + 'timeout' => "{$this->database->health_check_timeout}s", + 'retries' => $this->database->health_check_retries, + 'start_period' => "{$this->database->health_check_start_period}s", ], 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -197,6 +197,9 @@ class StartKeydb // 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"; diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index ceb1e8b85..17ed2a9a8 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -105,10 +105,10 @@ class StartMariadb 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ 'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', + 'interval' => "{$this->database->health_check_interval}s", + 'timeout' => "{$this->database->health_check_timeout}s", + 'retries' => $this->database->health_check_retries, + 'start_period' => "{$this->database->health_check_start_period}s", ], 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -202,6 +202,9 @@ class StartMariadb ]; } + 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"; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index c79789718..e5973e807 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -115,10 +115,10 @@ class StartMongodb 'echo', 'ok', ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', + 'interval' => "{$this->database->health_check_interval}s", + 'timeout' => "{$this->database->health_check_timeout}s", + 'retries' => $this->database->health_check_retries, + 'start_period' => "{$this->database->health_check_start_period}s", ], 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -253,6 +253,9 @@ class StartMongodb $docker_compose['services'][$container_name]['command'] = $commandParts; } + 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"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 0394d50b6..f9d75e0c8 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -105,10 +105,10 @@ class StartMysql 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ 'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', + 'interval' => "{$this->database->health_check_interval}s", + 'timeout' => "{$this->database->health_check_timeout}s", + 'retries' => $this->database->health_check_retries, + 'start_period' => "{$this->database->health_check_start_period}s", ], 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -203,6 +203,9 @@ class StartMysql ]; } + 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"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index da8b5dc4e..520cbdec4 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -112,10 +112,10 @@ class StartPostgresql 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ 'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', + 'interval' => "{$this->database->health_check_interval}s", + 'timeout' => "{$this->database->health_check_timeout}s", + 'retries' => $this->database->health_check_retries, + 'start_period' => "{$this->database->health_check_start_period}s", ], 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -213,6 +213,9 @@ class StartPostgresql $docker_compose['services'][$container_name]['command'] = $command; } + 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"; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index c31b099e4..e4bfd98e1 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -111,10 +111,10 @@ class StartRedis 'redis-cli', 'ping', ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', + 'interval' => "{$this->database->health_check_interval}s", + 'timeout' => "{$this->database->health_check_timeout}s", + 'retries' => $this->database->health_check_retries, + 'start_period' => "{$this->database->health_check_start_period}s", ], 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -194,6 +194,9 @@ class StartRedis $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"; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index dc9b6f5b5..e2d2aad92 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -299,6 +299,11 @@ class DatabasesController extends Controller 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], + 'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.'], + 'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.'], + 'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.'], + 'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.'], + 'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.'], ], ), ) @@ -565,9 +570,17 @@ class DatabasesController extends Controller } break; } + $allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']); + $healthCheckValidator = customApiValidator($request->all(), [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer|min:1', + 'health_check_timeout' => 'integer|min:1', + 'health_check_retries' => 'integer|min:1', + 'health_check_start_period' => 'integer|min:0', + ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); - if ($validator->fails() || ! empty($extraFields)) { - $errors = $validator->errors(); + if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) { + $errors = $validator->errors()->merge($healthCheckValidator->errors()); if (! empty($extraFields)) { foreach ($extraFields as $field) { $errors->add($field, 'This field is not allowed.'); diff --git a/app/Livewire/Project/Database/Health.php b/app/Livewire/Project/Database/Health.php new file mode 100644 index 000000000..33540d74e --- /dev/null +++ b/app/Livewire/Project/Database/Health.php @@ -0,0 +1,81 @@ +authorize('view', $this->database); + $this->syncData(); + } + + public function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->validate(); + $this->database->health_check_enabled = $this->healthCheckEnabled; + $this->database->health_check_interval = $this->healthCheckInterval; + $this->database->health_check_timeout = $this->healthCheckTimeout; + $this->database->health_check_retries = $this->healthCheckRetries; + $this->database->health_check_start_period = $this->healthCheckStartPeriod; + $this->database->save(); + } else { + $this->healthCheckEnabled = $this->database->health_check_enabled; + $this->healthCheckInterval = $this->database->health_check_interval; + $this->healthCheckTimeout = $this->database->health_check_timeout; + $this->healthCheckRetries = $this->database->health_check_retries; + $this->healthCheckStartPeriod = $this->database->health_check_start_period; + } + } + + public function instantSave() + { + $this->submit(); + } + + public function submit() + { + try { + $this->authorize('update', $this->database); + $this->syncData(true); + $this->dispatch('success', 'Health check updated. Restart the database to apply the changes.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + if (is_null($this->database->config_hash)) { + $this->database->isConfigurationChanged(true); + } else { + $this->dispatch('configurationChanged'); + } + } + } + + public function render() + { + return view('livewire.project.database.health'); + } +} diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 784e2c937..1e68046f9 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -44,11 +45,21 @@ class StandaloneClickhouse extends BaseModel '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']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'clickhouse_admin_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -111,6 +122,7 @@ class StandaloneClickhouse extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; + $newConfigHash .= $this->health_check_enabled.$this->health_check_interval.$this->health_check_timeout.$this->health_check_retries.$this->health_check_start_period; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index e07053c03..5f76be884 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -43,11 +44,21 @@ class StandaloneDragonfly extends BaseModel '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']; 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', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -110,6 +121,7 @@ class StandaloneDragonfly extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; + $newConfigHash .= $this->health_check_enabled.$this->health_check_interval.$this->health_check_timeout.$this->health_check_retries.$this->health_check_start_period; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 979f45a3d..6d3b5a82b 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -44,11 +45,21 @@ class StandaloneKeydb extends BaseModel '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', 'server_status']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'keydb_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -111,6 +122,7 @@ class StandaloneKeydb extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; + $newConfigHash .= $this->health_check_enabled.$this->health_check_interval.$this->health_check_timeout.$this->health_check_retries.$this->health_check_start_period; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index dba8a52f5..1058d8721 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -47,11 +48,21 @@ class StandaloneMariadb extends BaseModel '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']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'mariadb_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -114,6 +125,7 @@ class StandaloneMariadb extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; + $newConfigHash .= $this->health_check_enabled.$this->health_check_interval.$this->health_check_timeout.$this->health_check_retries.$this->health_check_start_period; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index e72f4f1c6..4657f1d5e 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -47,11 +48,21 @@ class StandaloneMongodb extends BaseModel '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']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -120,6 +131,7 @@ class StandaloneMongodb extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; + $newConfigHash .= $this->health_check_enabled.$this->health_check_interval.$this->health_check_timeout.$this->health_check_retries.$this->health_check_start_period; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 1c522d200..f3d0ad55f 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -48,11 +49,21 @@ class StandaloneMysql extends BaseModel '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']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', 'public_port_timeout' => 'integer', @@ -116,6 +127,7 @@ class StandaloneMysql extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; + $newConfigHash .= $this->health_check_enabled.$this->health_check_interval.$this->health_check_timeout.$this->health_check_retries.$this->health_check_start_period; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 57dfe5988..39482892d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -50,11 +51,21 @@ class StandalonePostgresql extends BaseModel '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']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'init_scripts' => 'array', 'postgres_password' => 'encrypted', 'public_port_timeout' => 'integer', @@ -158,6 +169,7 @@ class StandalonePostgresql extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method; + $newConfigHash .= $this->health_check_enabled.$this->health_check_interval.$this->health_check_timeout.$this->health_check_retries.$this->health_check_start_period; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index ef42d7f18..3d1d11138 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -43,11 +44,21 @@ class StandaloneRedis extends BaseModel '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']; protected $casts = [ + 'health_check_enabled' => 'boolean', + 'health_check_interval' => 'integer', + 'health_check_timeout' => 'integer', + 'health_check_retries' => 'integer', + 'health_check_start_period' => 'integer', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -115,6 +126,7 @@ class StandaloneRedis extends BaseModel public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; + $newConfigHash .= $this->health_check_enabled.$this->health_check_interval.$this->health_check_timeout.$this->health_check_retries.$this->health_check_start_period; $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Traits/HasDatabaseHealthCheck.php b/app/Traits/HasDatabaseHealthCheck.php new file mode 100644 index 000000000..2602ecb23 --- /dev/null +++ b/app/Traits/HasDatabaseHealthCheck.php @@ -0,0 +1,34 @@ +health_check_enabled ?? true); + } + + /** + * Build the Docker Compose healthcheck block for the given probe command. + * + * @param array $test The Docker `test` array (e.g. ['CMD', 'pg_isready']). + * @return array + */ + public function healthCheckConfiguration(array $test): array + { + return [ + 'test' => $test, + 'interval' => ($this->health_check_interval ?? 15).'s', + 'timeout' => ($this->health_check_timeout ?? 5).'s', + 'retries' => $this->health_check_retries ?? 5, + 'start_period' => ($this->health_check_start_period ?? 5).'s', + ]; + } +} diff --git a/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php b/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php new file mode 100644 index 000000000..63d7c3497 --- /dev/null +++ b/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php @@ -0,0 +1,47 @@ +tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->boolean('health_check_enabled')->default(true); + $table->integer('health_check_interval')->default(15); + $table->integer('health_check_timeout')->default(5); + $table->integer('health_check_retries')->default(5); + $table->integer('health_check_start_period')->default(5); + }); + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn([ + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', + ]); + }); + } + } +}; diff --git a/openapi.json b/openapi.json index e83538f2b..d3d25bacc 100644 --- a/openapi.json +++ b/openapi.json @@ -4605,6 +4605,26 @@ "mysql_conf": { "type": "string", "description": "MySQL conf" + }, + "health_check_enabled": { + "type": "boolean", + "description": "Enable the database healthcheck probe." + }, + "health_check_interval": { + "type": "integer", + "description": "Healthcheck interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Healthcheck timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Healthcheck retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Healthcheck start period in seconds." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 523d453ff..469a0c38d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2950,6 +2950,21 @@ paths: mysql_conf: type: string description: 'MySQL conf' + health_check_enabled: + type: boolean + description: 'Enable the database healthcheck probe.' + health_check_interval: + type: integer + description: 'Healthcheck interval in seconds.' + health_check_timeout: + type: integer + description: 'Healthcheck timeout in seconds.' + health_check_retries: + type: integer + description: 'Healthcheck retries count.' + health_check_start_period: + type: integer + description: 'Healthcheck start period in seconds.' type: object responses: '200': diff --git a/resources/views/livewire/project/database/configuration.blade.php b/resources/views/livewire/project/database/configuration.blade.php index 73f87c0e3..c58232200 100644 --- a/resources/views/livewire/project/database/configuration.blade.php +++ b/resources/views/livewire/project/database/configuration.blade.php @@ -15,6 +15,8 @@ href="{{ route('project.database.servers', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Servers Persistent Storage + Health Checks @can('update', $database) Import Backup @@ -57,6 +59,8 @@ @elseif ($currentRoute === 'project.database.persistent-storage') + @elseif ($currentRoute === 'project.database.health-checks') + @elseif ($currentRoute === 'project.database.import-backup') @elseif ($currentRoute === 'project.database.webhooks') diff --git a/resources/views/livewire/project/database/health.blade.php b/resources/views/livewire/project/database/health.blade.php new file mode 100644 index 000000000..2e70f79b2 --- /dev/null +++ b/resources/views/livewire/project/database/health.blade.php @@ -0,0 +1,26 @@ +
+
+

Health Checks

+ Save +
+
Configure how Docker checks this database's health. A higher interval lowers + dockerd/containerd CPU and load on servers running many databases. Restart the + database to apply changes.
+
+ + @if ($healthCheckEnabled) +
+ + + + +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index aed37a086..e4d6f477f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -243,6 +243,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/servers', DatabaseConfiguration::class)->name('project.database.servers'); Route::get('/import-backup', DatabaseConfiguration::class)->name('project.database.import-backup')->middleware('can.update.resource'); Route::get('/persistent-storage', DatabaseConfiguration::class)->name('project.database.persistent-storage'); + Route::get('/health-checks', DatabaseConfiguration::class)->name('project.database.health-checks'); Route::get('/webhooks', DatabaseConfiguration::class)->name('project.database.webhooks'); Route::get('/resource-limits', DatabaseConfiguration::class)->name('project.database.resource-limits'); Route::get('/resource-operations', DatabaseConfiguration::class)->name('project.database.resource-operations'); diff --git a/tests/Feature/DatabaseHealthCheckTest.php b/tests/Feature/DatabaseHealthCheckTest.php new file mode 100644 index 000000000..8bc1e4e92 --- /dev/null +++ b/tests/Feature/DatabaseHealthCheckTest.php @@ -0,0 +1,45 @@ +isHealthcheckEnabled())->toBeTrue(); +}); + +it('builds the compose healthcheck block from the model timing fields', function () { + $database = new StandalonePostgresql([ + 'health_check_interval' => 30, + 'health_check_timeout' => 7, + 'health_check_retries' => 4, + 'health_check_start_period' => 12, + ]); + + $config = $database->healthCheckConfiguration(['CMD', 'pg_isready']); + + expect($config)->toBe([ + 'test' => ['CMD', 'pg_isready'], + 'interval' => '30s', + 'timeout' => '7s', + 'retries' => 4, + 'start_period' => '12s', + ]); +}); + +it('falls back to safe defaults when timing fields are missing', function () { + $database = new StandalonePostgresql; + + $config = $database->healthCheckConfiguration(['CMD', 'pg_isready']); + + expect($config['interval'])->toBe('15s') + ->and($config['timeout'])->toBe('5s') + ->and($config['retries'])->toBe(5) + ->and($config['start_period'])->toBe('5s'); +}); + +it('reports the healthcheck as disabled when the flag is false', function () { + $database = new StandalonePostgresql(['health_check_enabled' => false]); + + expect($database->isHealthcheckEnabled())->toBeFalse(); +});