use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::{debug, error, info, warn};
use crate::connection::DatabaseClient;
use crate::error::{Result, SurqlError};
use crate::migration::MIGRATION_TABLE_NAME;
use crate::orchestration::environment::EnvironmentConfig;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HealthStatus {
pub environment: String,
pub is_healthy: bool,
pub can_connect: bool,
#[serde(default)]
pub migration_table_exists: bool,
#[serde(default)]
pub error: Option<String>,
}
impl HealthStatus {
pub fn healthy(environment: impl Into<String>, migration_table_exists: bool) -> Self {
Self {
environment: environment.into(),
is_healthy: true,
can_connect: true,
migration_table_exists,
error: None,
}
}
pub fn unhealthy(environment: impl Into<String>, error: impl Into<String>) -> Self {
Self {
environment: environment.into(),
is_healthy: false,
can_connect: false,
migration_table_exists: false,
error: Some(error.into()),
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct HealthCheck;
impl HealthCheck {
pub fn new() -> Self {
Self
}
pub async fn check_environment(self, env: &EnvironmentConfig) -> Result<HealthStatus> {
let client = match build_client(&env.connection) {
Ok(client) => client,
Err(err) => {
warn!(environment = %env.name, error = %err, "health_client_build_failed");
return Ok(HealthStatus::unhealthy(env.name.clone(), err.to_string()));
}
};
let can_connect = check_connect(&client, &env.name).await;
if !can_connect {
let _ = client.disconnect().await;
return Ok(HealthStatus {
environment: env.name.clone(),
is_healthy: false,
can_connect: false,
migration_table_exists: false,
error: Some("Cannot connect to database".into()),
});
}
let migration_table_exists = check_migration_table(&client, &env.name).await;
let _ = client.disconnect().await;
info!(
environment = %env.name,
migration_table_exists,
"environment_health_checked"
);
Ok(HealthStatus::healthy(
env.name.clone(),
migration_table_exists,
))
}
pub async fn check_connectivity(self, env: &EnvironmentConfig) -> Result<bool> {
let client = build_client(&env.connection)?;
let ok = check_connect(&client, &env.name).await;
let _ = client.disconnect().await;
Ok(ok)
}
pub async fn check_schema_integrity(
self,
env: &EnvironmentConfig,
) -> Result<HashMap<String, bool>> {
let mut checks: HashMap<String, bool> = HashMap::new();
checks.insert("connectivity".into(), false);
checks.insert("migration_table".into(), false);
let client = match build_client(&env.connection) {
Ok(client) => client,
Err(err) => {
debug!(environment = %env.name, error = %err, "schema_integrity_client_failed");
return Ok(checks);
}
};
let connectivity = check_connect(&client, &env.name).await;
checks.insert("connectivity".into(), connectivity);
if !connectivity {
let _ = client.disconnect().await;
return Ok(checks);
}
let table_exists = check_migration_table(&client, &env.name).await;
checks.insert("migration_table".into(), table_exists);
let _ = client.disconnect().await;
Ok(checks)
}
pub async fn verify_all_environments(
self,
environments: &[EnvironmentConfig],
) -> Result<HashMap<String, HealthStatus>> {
let mut out = HashMap::new();
for env in environments {
let status = self.check_environment(env).await?;
out.insert(env.name.clone(), status);
}
Ok(out)
}
}
fn build_client(connection: &crate::connection::ConnectionConfig) -> Result<DatabaseClient> {
DatabaseClient::new(connection.clone())
}
async fn check_connect(client: &DatabaseClient, environment: &str) -> bool {
if let Err(err) = client.connect().await {
warn!(environment = %environment, error = %err, "connectivity_check_failed");
return false;
}
match client.query("RETURN 1").await {
Ok(_) => {
debug!(environment = %environment, "connectivity_check_passed");
true
}
Err(err) => {
warn!(environment = %environment, error = %err, "connectivity_check_failed");
false
}
}
}
async fn check_migration_table(client: &DatabaseClient, environment: &str) -> bool {
let surql = format!("SELECT * FROM {MIGRATION_TABLE_NAME} LIMIT 1");
match client.query(&surql).await {
Ok(value) => {
debug!(environment = %environment, "migration_table_probe_ok");
!matches!(value, Value::Null)
}
Err(err) => {
match err {
SurqlError::Query { .. } => {
debug!(environment = %environment, "migration_table_not_found");
false
}
other => {
error!(environment = %environment, error = %other, "migration_table_check_error");
false
}
}
}
}
}
pub async fn check_environment_health(env: &EnvironmentConfig) -> Result<HealthStatus> {
HealthCheck::new().check_environment(env).await
}
pub async fn verify_connectivity(env: &EnvironmentConfig) -> Result<bool> {
HealthCheck::new().check_connectivity(env).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::connection::ConnectionConfig;
fn sample_env(name: &str) -> EnvironmentConfig {
let cfg = ConnectionConfig::builder()
.url("ws://127.0.0.1:65535")
.namespace("ns")
.database(name)
.timeout(1.0)
.retry_max_attempts(1)
.retry_min_wait(0.1)
.retry_max_wait(1.0)
.build()
.expect("config");
EnvironmentConfig::builder(name, cfg).build().unwrap()
}
#[test]
fn healthy_constructor_sets_flags() {
let s = HealthStatus::healthy("prod", true);
assert!(s.is_healthy);
assert!(s.can_connect);
assert!(s.migration_table_exists);
assert!(s.error.is_none());
}
#[test]
fn unhealthy_constructor_sets_error() {
let s = HealthStatus::unhealthy("prod", "refused");
assert!(!s.is_healthy);
assert!(!s.can_connect);
assert_eq!(s.error.as_deref(), Some("refused"));
}
#[tokio::test]
async fn unreachable_host_reports_unhealthy() {
let env = sample_env("unreach_host");
let status = HealthCheck::new().check_environment(&env).await.unwrap();
assert!(!status.is_healthy);
assert!(!status.can_connect);
assert!(!status.migration_table_exists);
assert!(status.error.is_some());
}
#[tokio::test]
async fn schema_integrity_on_unreachable_returns_false_checks() {
let env = sample_env("unreach_integrity");
let checks = HealthCheck::new()
.check_schema_integrity(&env)
.await
.unwrap();
assert_eq!(checks.get("connectivity"), Some(&false));
assert_eq!(checks.get("migration_table"), Some(&false));
}
#[tokio::test]
async fn verify_connectivity_false_on_unreachable() {
let env = sample_env("unreach_verify");
let ok = verify_connectivity(&env).await.unwrap();
assert!(!ok);
}
#[tokio::test]
async fn verify_all_environments_aggregates_results() {
let envs = vec![sample_env("agg_a"), sample_env("agg_b")];
let map = HealthCheck::new()
.verify_all_environments(&envs)
.await
.unwrap();
assert_eq!(map.len(), 2);
for status in map.values() {
assert!(!status.is_healthy);
}
}
}