diff --git a/src/services/config.rs b/src/services/config.rs index ddb56c1..9142c78 100644 --- a/src/services/config.rs +++ b/src/services/config.rs @@ -1,7 +1,7 @@ #![allow(dead_code)] use crate::core::context::Context; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json; use std::collections::HashMap; use std::fs::File; @@ -12,7 +12,7 @@ use toml; use tracing::info; use uuid::Uuid; -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum DbType { Mysql, @@ -49,7 +49,7 @@ impl DbType { } #[allow(dead_code)] -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct DatabaseConfig { pub name: String, pub database: String, @@ -68,7 +68,7 @@ pub struct DatabaseConfig { } #[allow(dead_code)] -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct DatabasesConfig { pub databases: Vec, } @@ -98,6 +98,86 @@ pub struct InputDatabasesConfig { pub databases: Vec, } +fn required(opt: &Option, db_name: &str, field_name: &str) -> Result { + match opt { + Some(v) => Ok(v.clone()), + None => Err(format!( + "Missing required field '{}' for database '{}'", + field_name, db_name + )), + } +} + +fn optional(opt: &Option) -> T { + opt.clone().unwrap_or_default() +} + +pub fn build_config(db: InputDatabaseConfig) -> Result { + if Uuid::parse_str(&db.generated_id).is_err() { + return Err(format!("Invalid UUID for database '{}'", db.name)); + } + + let username = match db.db_type { + DbType::Postgresql | DbType::PostgresqlCluster | DbType::Mysql | DbType::Mariadb + | DbType::Mssql => required(&db.username, &db.name, "username")?, + _ => optional(&db.username), + }; + let password = match db.db_type { + DbType::Postgresql | DbType::PostgresqlCluster | DbType::Mysql | DbType::Mariadb + | DbType::Mssql => required(&db.password, &db.name, "password")?, + _ => optional(&db.password), + }; + let host = match db.db_type { + DbType::Postgresql | DbType::PostgresqlCluster | DbType::Mysql | DbType::Mariadb + | DbType::MongoDB | DbType::Redis | DbType::Firebird | DbType::Valkey | DbType::Mssql => { + required(&db.host, &db.name, "host")? + } + DbType::Sqlite | DbType::DockerVolume => optional(&db.host), + }; + let port = match db.db_type { + DbType::Postgresql | DbType::PostgresqlCluster | DbType::Mysql | DbType::Mariadb + | DbType::MongoDB | DbType::Redis | DbType::Firebird | DbType::Valkey | DbType::Mssql => { + required(&db.port, &db.name, "port")? + } + DbType::Sqlite | DbType::DockerVolume => db.port.unwrap_or(0), + }; + let database_name = match db.db_type { + DbType::Sqlite | DbType::Redis | DbType::Valkey | DbType::DockerVolume => { + optional(&db.database) + } + DbType::PostgresqlCluster => db.database.clone().unwrap_or_else(|| "postgres".to_string()), + _ => required(&db.database, &db.name, "database")?, + }; + let path_val = match db.db_type { + DbType::Sqlite => required(&db.path, &db.name, "path")?, + _ => optional(&db.path), + }; + let max_packet_size = match db.db_type { + DbType::Mysql | DbType::Mariadb => db.max_packet_size.unwrap_or_else(|| "512M".to_string()), + _ => String::new(), + }; + let volume_name = match db.db_type { + DbType::DockerVolume => required(&db.volume_name, &db.name, "volume_name")?, + _ => optional(&db.volume_name), + }; + + Ok(DatabaseConfig { + name: db.name, + database: database_name, + db_type: db.db_type, + username, + password, + host, + port, + generated_id: db.generated_id, + path: path_val, + max_packet_size, + volume_name, + container_name: db.container_name.clone(), + options: db.options.unwrap_or_default(), + }) +} + pub struct ConfigService { ctx: Arc, } @@ -150,128 +230,27 @@ impl ConfigService { _ => return Err("Unsupported config file format. Use .json or .toml".to_string()), }; - fn required( - opt: &Option, - db_name: &str, - field_name: &str, - ) -> Result { - match opt { - Some(v) => Ok(v.clone()), - None => { - let msg = format!( - "Missing required field '{}' for database '{}'", - field_name, db_name - ); - Err(msg) - } - } - } - - fn optional(opt: &Option) -> T - where - T: Default, - { - opt.clone().unwrap_or_default() - } - let mut databases = Vec::with_capacity(input_config.databases.len()); - for db in input_config.databases { - if Uuid::parse_str(&db.generated_id).is_err() { - return Err(format!("Invalid UUID for database '{}'", db.name)); - } - - let username = match db.db_type { - DbType::Postgresql - | DbType::PostgresqlCluster - | DbType::Mysql - | DbType::Mariadb - | DbType::Mssql => required(&db.username, &db.name, "username")?, - _ => optional(&db.username), - }; - - let password = match db.db_type { - DbType::Postgresql - | DbType::PostgresqlCluster - | DbType::Mysql - | DbType::Mariadb - | DbType::Mssql => required(&db.password, &db.name, "password")?, - _ => optional(&db.password), - }; - - let host = match db.db_type { - DbType::Postgresql - | DbType::PostgresqlCluster - | DbType::Mysql - | DbType::Mariadb - | DbType::MongoDB - | DbType::Redis - | DbType::Firebird - | DbType::Valkey - | DbType::Mssql => required(&db.host, &db.name, "host")?, - DbType::Sqlite | DbType::DockerVolume => optional(&db.host), - }; - - let port = match db.db_type { - DbType::Postgresql - | DbType::PostgresqlCluster - | DbType::Mysql - | DbType::Mariadb - | DbType::MongoDB - | DbType::Redis - | DbType::Firebird - | DbType::Valkey - | DbType::Mssql => required(&db.port, &db.name, "port")?, - DbType::Sqlite | DbType::DockerVolume => db.port.unwrap_or(0), - }; - - let database_name = match db.db_type { - DbType::Sqlite | DbType::Redis | DbType::Valkey | DbType::DockerVolume => { - optional(&db.database) - } - DbType::PostgresqlCluster => db - .database - .clone() - .unwrap_or_else(|| "postgres".to_string()), - _ => required(&db.database, &db.name, "database")?, - }; - - let path_val = match db.db_type { - DbType::Sqlite => required(&db.path, &db.name, "path")?, - _ => optional(&db.path), - }; - - let max_packet_size = match db.db_type { - DbType::Mysql | DbType::Mariadb => { - db.max_packet_size.unwrap_or_else(|| "512M".to_string()) - } - _ => String::new(), - }; - - let volume_name = match db.db_type { - DbType::DockerVolume => required(&db.volume_name, &db.name, "volume_name")?, - _ => optional(&db.volume_name), - }; - let container_name = db.container_name.clone(); - - databases.push(DatabaseConfig { - name: db.name, - database: database_name, - db_type: db.db_type, - username, - password, - host, - port, - generated_id: db.generated_id, - path: path_val, - max_packet_size, - volume_name, - container_name, - options: db.options.unwrap_or_default(), - }); + databases.push(build_config(db)?); } info!("Databases: {} instances loaded", databases.len()); Ok(DatabasesConfig { databases }) } + + /// Like `load`, but a missing or unreadable local file is not fatal: it logs a + /// warning and returns an empty set so the agent can still operate on + /// dashboard-defined databases. + pub fn load_optional(&self, file_path: Option<&str>) -> DatabasesConfig { + match self.load(file_path) { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!( + "Local databases config unavailable ({e}); continuing with dashboard-defined databases only" + ); + DatabasesConfig { databases: Vec::new() } + } + } + } } diff --git a/src/tests/services/config_tests.rs b/src/tests/services/config_tests.rs index 0936617..62b17e9 100644 --- a/src/tests/services/config_tests.rs +++ b/src/tests/services/config_tests.rs @@ -1,6 +1,7 @@ use crate::core::context::Context; use crate::services::api::ApiClient; use crate::services::config::ConfigService; +use crate::services::config::{build_config, DatabasesConfig, InputDatabaseConfig}; use crate::utils::edge_key::EdgeKey; use std::io::Write; use std::sync::Arc; @@ -264,3 +265,73 @@ fn docker_volume_requires_volume_name() { let err = service.load(Some(file.path().to_str().unwrap())).unwrap_err(); assert!(err.contains("volume_name"), "error was: {err}"); } + +#[test] +fn build_config_applies_type_defaults() { + let input: InputDatabaseConfig = serde_json::from_str( + r#"{ + "name": "cluster1", + "type": "postgresql-cluster", + "username": "postgres", + "password": "p", + "port": 5432, + "host": "localhost", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681" + }"#, + ) + .unwrap(); + + let cfg = build_config(input).unwrap(); + assert_eq!(cfg.db_type.as_str(), "postgresql-cluster"); + assert_eq!(cfg.database, "postgres"); // cluster default +} + +#[test] +fn build_config_rejects_missing_required_field() { + let input: InputDatabaseConfig = serde_json::from_str( + r#"{ + "name": "pg", + "type": "postgresql", + "username": "postgres", + "port": 5432, + "host": "localhost", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681" + }"#, + ) + .unwrap(); + + let err = build_config(input).unwrap_err(); + assert!(err.contains("password"), "unexpected error: {err}"); +} + +#[test] +fn load_optional_returns_empty_when_file_missing() { + let service = ConfigService::new(test_context()); + let cfg = service.load_optional(Some("/nonexistent/path/does-not-exist.json")); + assert!(cfg.databases.is_empty()); +} + +#[test] +fn databases_config_roundtrips_through_serde() { + let input: InputDatabaseConfig = serde_json::from_str( + r#"{ + "name": "pg", + "type": "postgresql", + "database": "app", + "username": "postgres", + "password": "secret", + "port": 5432, + "host": "localhost", + "generated_id": "16678159-ff7e-4c97-8c83-0adeff214681" + }"#, + ) + .unwrap(); + let cfg = build_config(input).unwrap(); + let wrapped = DatabasesConfig { databases: vec![cfg] }; + + let json = serde_json::to_string(&wrapped).unwrap(); + let back: DatabasesConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.databases[0].name, "pg"); + assert_eq!(back.databases[0].db_type.as_str(), "postgresql"); + assert_eq!(back.databases[0].password, "secret"); +}