From 0902360cf4665f960f10b6ddfd319302699e8944 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Thu, 4 Jun 2026 14:42:50 -0300 Subject: [PATCH] feat(database): add listDatabases function to list configured databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `database::listDatabases` returning config details for every configured database: logical name, driver (postgres/mysql/sqlite), credential-redacted connection URL, pool settings, and TLS mode. Config-only by design — no health checks or live pool statistics. - Extend AppState with a live `config` snapshot, swapped together with pools inside apply_config's critical section so hot-reload never exposes new pools paired with stale config - Reuse existing config::redact_url and transaction::driver_system; report ca_cert as a presence boolean to avoid leaking paths - Sort entries by name for deterministic output - 7 unit tests (redaction, TLS path-leak guard, sort/count, envelope) plus 2 integration tests including a hot-reload snapshot guard - Document the function in README and skills/SKILL.md --- database/Cargo.lock | 2 +- database/README.md | 1 + database/skills/SKILL.md | 3 + database/src/configuration.rs | 11 +- database/src/handlers/begin_transaction.rs | 1 + database/src/handlers/commit_transaction.rs | 3 + database/src/handlers/execute.rs | 1 + database/src/handlers/list_databases.rs | 222 ++++++++++++++++++ database/src/handlers/mod.rs | 6 + database/src/handlers/prepare.rs | 1 + database/src/handlers/query.rs | 1 + database/src/handlers/rollback_transaction.rs | 3 + database/src/handlers/run_statement.rs | 2 + database/src/handlers/transaction.rs | 1 + database/src/handlers/transaction_execute.rs | 3 + database/src/main.rs | 23 +- database/tests/integration.rs | 40 ++++ 17 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 database/src/handlers/list_databases.rs diff --git a/database/Cargo.lock b/database/Cargo.lock index f72d54d9..80e21e38 100644 --- a/database/Cargo.lock +++ b/database/Cargo.lock @@ -596,7 +596,7 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "database" -version = "0.2.2" +version = "0.2.5" dependencies = [ "anyhow", "async-trait", diff --git a/database/README.md b/database/README.md index 31f4bf58..5403901c 100644 --- a/database/README.md +++ b/database/README.md @@ -175,6 +175,7 @@ const { rows } = await iii.trigger({ | `database::transactionExecute` | Write SQL inside an interactive transaction. Same envelope as `execute`. Rejects bare `BEGIN`/`COMMIT`/`ROLLBACK`/`SAVEPOINT`/`SET TRANSACTION` with `INVALID_PARAM` — finalize via the dedicated handlers below. | | `database::commitTransaction` | Commit and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. | | `database::rollbackTransaction` | Rollback and finalize an interactive transaction. Subsequent calls against the same id return `TRANSACTION_NOT_FOUND`. | +| `database::listDatabases` | List configured databases. Returns `{ databases, count }`; each entry has `name`, `driver`, credential-redacted `url`, `pool` settings, and `tls` (`mode`, `ca_cert_present`, `trust_native`). Config only — no health checks or live pool stats. | ## Triggers diff --git a/database/skills/SKILL.md b/database/skills/SKILL.md index 1336c290..28d04aae 100644 --- a/database/skills/SKILL.md +++ b/database/skills/SKILL.md @@ -68,6 +68,9 @@ point. Placeholder syntax: `?` for SQLite and MySQL, `$1`/`$2`/… for Postgres. transaction. - `database::rollbackTransaction` — roll back and finalize an interactive transaction. +- `database::listDatabases` — list every configured database with its + driver, credential-redacted connection URL, pool settings, and TLS mode. + Config details only; no health checks or live pool statistics. Interactive transactions auto-roll back when `timeout_ms` elapses (default 30 s, max 5 min). Prepared handles default to a 1 h TTL (max 24 h) with no diff --git a/database/src/configuration.rs b/database/src/configuration.rs index 2fd3ff0d..c7e66787 100644 --- a/database/src/configuration.rs +++ b/database/src/configuration.rs @@ -79,11 +79,16 @@ pub async fn build_pools(cfg: &WorkerConfig) -> Result, St Ok(pools) } -/// Replace in-memory pools with freshly built ones from `cfg`. pub async fn apply_config(state: &AppState, cfg: WorkerConfig) -> Result<(), String> { let new_pools = build_pools(&cfg).await?; - let mut guard = state.pools.write().await; - *guard = new_pools; + // Swap pools and the config snapshot inside one critical section (pools + // lock first, then config) so a concurrent reader never observes new + // pools paired with the old config or vice-versa. A failed build above + // leaves both untouched. + let mut pools_guard = state.pools.write().await; + let mut config_guard = state.config.write().await; + *pools_guard = new_pools; + *config_guard = cfg; Ok(()) } diff --git a/database/src/handlers/begin_transaction.rs b/database/src/handlers/begin_transaction.rs index 071fb72f..90008626 100644 --- a/database/src/handlers/begin_transaction.rs +++ b/database/src/handlers/begin_transaction.rs @@ -134,6 +134,7 @@ pub(crate) mod tests { pools.insert("primary".to_string(), Pool::Sqlite(pool)); AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())), handles: Arc::new(HandleRegistry::new()), transactions: TxRegistry::new(), log: Logger::new(), diff --git a/database/src/handlers/commit_transaction.rs b/database/src/handlers/commit_transaction.rs index 9ea12fd0..75277baa 100644 --- a/database/src/handlers/commit_transaction.rs +++ b/database/src/handlers/commit_transaction.rs @@ -157,6 +157,9 @@ mod tests { pools.insert("primary".to_string(), crate::pool::Pool::Sqlite(pool)); let st = crate::handlers::AppState { pools: std::sync::Arc::new(tokio::sync::RwLock::new(pools)), + config: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::config::WorkerConfig::default(), + )), handles: std::sync::Arc::new(crate::handle::HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), diff --git a/database/src/handlers/execute.rs b/database/src/handlers/execute.rs index 0d068fe8..64128f3e 100644 --- a/database/src/handlers/execute.rs +++ b/database/src/handlers/execute.rs @@ -76,6 +76,7 @@ mod tests { pools.insert("primary".to_string(), Pool::Sqlite(pool)); AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())), handles: Arc::new(HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), diff --git a/database/src/handlers/list_databases.rs b/database/src/handlers/list_databases.rs new file mode 100644 index 00000000..44d8d29a --- /dev/null +++ b/database/src/handlers/list_databases.rs @@ -0,0 +1,222 @@ +//! `database::listDatabases` — config details for every registered database. +//! Config-only: no health checks, no live pool stats. Credentials are +//! scrubbed from the connection URL before it leaves the process. + +use super::AppState; +use crate::config::{redact_url, TlsMode}; +use crate::transaction::driver_system; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct ListDatabasesReq {} + +/// Pool settings echoed back from config (no live stats). +#[derive(Debug, Serialize, JsonSchema)] +pub struct PoolInfo { + pub max: u32, + pub idle_timeout_ms: u64, + pub acquire_timeout_ms: u64, +} + +/// TLS settings. `ca_cert` is reported as a presence boolean only — never +/// the path, which would leak filesystem layout. +#[derive(Debug, Serialize, JsonSchema)] +pub struct TlsInfo { + pub mode: TlsMode, + pub ca_cert_present: bool, + pub trust_native: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct DatabaseInfo { + /// Logical key (e.g. "primary"). + pub name: String, + /// "postgres" | "mysql" | "sqlite". + pub driver: String, + /// Connection URL with credentials redacted. + pub url: String, + pub pool: PoolInfo, + pub tls: TlsInfo, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ListDatabasesResp { + pub databases: Vec, + pub count: usize, +} + +pub async fn handle(state: &AppState, _req: ListDatabasesReq) -> Result { + let cfg = state.config.read().await; + let mut databases: Vec = cfg + .databases + .iter() + .map(|(name, db)| DatabaseInfo { + name: name.clone(), + driver: driver_system(db.driver).to_string(), + url: redact_url(&db.url), + pool: PoolInfo { + max: db.pool.max, + idle_timeout_ms: db.pool.idle_timeout_ms, + acquire_timeout_ms: db.pool.acquire_timeout_ms, + }, + tls: TlsInfo { + mode: db.tls.mode, + ca_cert_present: db.tls.ca_cert.is_some(), + trust_native: db.tls.trust_native, + }, + }) + .collect(); + // HashMap iteration order is nondeterministic; sort so output is stable. + databases.sort_by(|a, b| a.name.cmp(&b.name)); + let count = databases.len(); + Ok(ListDatabasesResp { databases, count }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::WorkerConfig; + use crate::handle::HandleRegistry; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::RwLock; + + fn state_from_yaml(yaml: &str) -> AppState { + let cfg = WorkerConfig::from_yaml(yaml).unwrap(); + AppState { + // The handler reads only `config`; pools are never touched. + pools: Arc::new(RwLock::new(HashMap::new())), + config: Arc::new(RwLock::new(cfg)), + handles: Arc::new(HandleRegistry::new()), + transactions: crate::transaction::TxRegistry::new(), + log: iii_observability::Logger::new(), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn returns_sqlite_primary_with_config_defaults() { + // Arrange + let st = state_from_yaml("databases:\n primary:\n url: \"sqlite::memory:\"\n"); + + // Act + let resp = handle(&st, ListDatabasesReq::default()).await.unwrap(); + + // Assert + assert_eq!(resp.count, 1); + let db = &resp.databases[0]; + assert_eq!(db.name, "primary"); + assert_eq!(db.driver, "sqlite"); + assert_eq!(db.pool.max, 10); + assert_eq!(db.pool.idle_timeout_ms, 30_000); + assert_eq!(db.pool.acquire_timeout_ms, 5_000); + assert_eq!(db.tls.mode, TlsMode::Require); + assert!(!db.tls.ca_cert_present); + assert!(db.tls.trust_native); + } + + #[tokio::test(flavor = "multi_thread")] + async fn redacts_postgres_password() { + // Arrange + let st = state_from_yaml( + "databases:\n main:\n url: \"postgres://user:secret@host:5432/db\"\n", + ); + + // Act + let resp = handle(&st, ListDatabasesReq::default()).await.unwrap(); + + // Assert + let db = &resp.databases[0]; + assert_eq!(db.driver, "postgres"); + assert_eq!(db.url, "postgres://***@host:5432/db"); + assert!(!db.url.contains("secret")); + assert!(!db.url.contains("user:")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn redacts_mysql_password() { + // Arrange + let st = state_from_yaml( + "databases:\n main:\n url: \"mysql://admin:pw@127.0.0.1:3306/test\"\n", + ); + + // Act + let resp = handle(&st, ListDatabasesReq::default()).await.unwrap(); + + // Assert + let db = &resp.databases[0]; + assert_eq!(db.driver, "mysql"); + assert_eq!(db.url, "mysql://***@127.0.0.1:3306/test"); + assert!(!db.url.contains("pw@")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn sqlite_url_passes_through_without_credentials() { + // Arrange + let st = state_from_yaml("databases:\n mem:\n url: \"sqlite::memory:\"\n"); + + // Act + let resp = handle(&st, ListDatabasesReq::default()).await.unwrap(); + + // Assert + assert_eq!(resp.databases[0].url, "sqlite::memory:"); + assert_eq!(resp.databases[0].driver, "sqlite"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn sorts_databases_by_name_and_counts() { + // Arrange + let st = state_from_yaml( + "databases:\n zeta:\n url: \"sqlite::memory:\"\n alpha:\n url: \"sqlite::memory:\"\n mid:\n url: \"sqlite::memory:\"\n", + ); + + // Act + let resp = handle(&st, ListDatabasesReq::default()).await.unwrap(); + + // Assert + assert_eq!(resp.count, 3); + let names: Vec<&str> = resp.databases.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["alpha", "mid", "zeta"]); + } + + #[tokio::test(flavor = "multi_thread")] + async fn reports_tls_overrides_without_leaking_cert_path() { + // Arrange + let st = state_from_yaml( + "databases:\n main:\n url: \"postgres://u@host/db\"\n tls:\n mode: verify-full\n ca_cert: /etc/ssl/private-ca.pem\n trust_native: false\n", + ); + + // Act + let resp = handle(&st, ListDatabasesReq::default()).await.unwrap(); + + // Assert + let json = serde_json::to_value(&resp).unwrap(); + let db = &json["databases"][0]; + assert_eq!(db["tls"]["mode"], "verify-full"); + assert_eq!(db["tls"]["ca_cert_present"], true); + assert_eq!(db["tls"]["trust_native"], false); + assert!(!json.to_string().contains("private-ca.pem")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn response_envelope_matches_sibling_convention() { + // Arrange + let st = state_from_yaml("databases:\n primary:\n url: \"sqlite::memory:\"\n"); + + // Act + let resp = handle(&st, ListDatabasesReq::default()).await.unwrap(); + + // Assert + let json = serde_json::to_value(&resp).unwrap(); + let mut keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(|k| k.as_str()) + .collect(); + keys.sort_unstable(); + assert_eq!(keys, ["count", "databases"]); + assert!(json["databases"].is_array()); + assert!(json["count"].is_number()); + } +} diff --git a/database/src/handlers/mod.rs b/database/src/handlers/mod.rs index 8def6d25..7ff133c4 100644 --- a/database/src/handlers/mod.rs +++ b/database/src/handlers/mod.rs @@ -2,6 +2,7 @@ //! payload from the SDK, validates it, dispatches to the configured pool, //! and serializes the result. +use crate::config::WorkerConfig; use crate::error::DbError; use crate::handle::HandleRegistry; use crate::pool::Pool; @@ -14,6 +15,7 @@ use tokio::sync::RwLock; pub mod begin_transaction; pub mod commit_transaction; pub mod execute; +pub mod list_databases; pub mod prepare; pub mod query; pub mod rollback_transaction; @@ -28,6 +30,10 @@ pub(crate) use query::rows_to_objects as query_rows_to_objects; #[derive(Clone)] pub struct AppState { pub pools: Arc>>, + /// Live config snapshot the pools were built from. Swapped together with + /// `pools` on hot-reload (see `configuration::apply_config`) so readers + /// never observe new pools paired with stale config. + pub config: Arc>, pub handles: Arc, pub transactions: TxRegistry, pub log: Logger, diff --git a/database/src/handlers/prepare.rs b/database/src/handlers/prepare.rs index 44423454..9c7142f7 100644 --- a/database/src/handlers/prepare.rs +++ b/database/src/handlers/prepare.rs @@ -85,6 +85,7 @@ mod tests { pools.insert("primary".to_string(), Pool::Sqlite(pool)); AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())), handles: Arc::new(HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), diff --git a/database/src/handlers/query.rs b/database/src/handlers/query.rs index 2c082c8a..a4026cf5 100644 --- a/database/src/handlers/query.rs +++ b/database/src/handlers/query.rs @@ -112,6 +112,7 @@ mod tests { pools.insert("primary".to_string(), Pool::Sqlite(pool)); AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())), handles: Arc::new(HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), diff --git a/database/src/handlers/rollback_transaction.rs b/database/src/handlers/rollback_transaction.rs index 1dcf6ba9..6df05cf8 100644 --- a/database/src/handlers/rollback_transaction.rs +++ b/database/src/handlers/rollback_transaction.rs @@ -130,6 +130,9 @@ mod tests { pools.insert("primary".to_string(), crate::pool::Pool::Sqlite(pool)); let st = crate::handlers::AppState { pools: std::sync::Arc::new(tokio::sync::RwLock::new(pools)), + config: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::config::WorkerConfig::default(), + )), handles: std::sync::Arc::new(crate::handle::HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), diff --git a/database/src/handlers/run_statement.rs b/database/src/handlers/run_statement.rs index d939639d..5c04ab9b 100644 --- a/database/src/handlers/run_statement.rs +++ b/database/src/handlers/run_statement.rs @@ -59,6 +59,7 @@ mod tests { pools.insert("primary".to_string(), Pool::Sqlite(pool)); AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())), handles: Arc::new(HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), @@ -75,6 +76,7 @@ mod tests { pools.insert("primary".to_string(), Pool::Sqlite(pool)); let st = AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())), handles: Arc::new(HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), diff --git a/database/src/handlers/transaction.rs b/database/src/handlers/transaction.rs index c68b1e4d..5ad9737d 100644 --- a/database/src/handlers/transaction.rs +++ b/database/src/handlers/transaction.rs @@ -135,6 +135,7 @@ mod tests { pools.insert("primary".to_string(), Pool::Sqlite(pool)); AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(crate::config::WorkerConfig::default())), handles: Arc::new(HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), diff --git a/database/src/handlers/transaction_execute.rs b/database/src/handlers/transaction_execute.rs index 0ce970d7..1a16e88c 100644 --- a/database/src/handlers/transaction_execute.rs +++ b/database/src/handlers/transaction_execute.rs @@ -280,6 +280,9 @@ mod tests { pools.insert("primary".to_string(), crate::pool::Pool::Sqlite(pool)); let st = crate::handlers::AppState { pools: std::sync::Arc::new(tokio::sync::RwLock::new(pools)), + config: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::config::WorkerConfig::default(), + )), handles: std::sync::Arc::new(crate::handle::HandleRegistry::new()), transactions: crate::transaction::TxRegistry::new(), log: iii_observability::Logger::new(), diff --git a/database/src/main.rs b/database/src/main.rs index 5c8183af..c897a4d3 100644 --- a/database/src/main.rs +++ b/database/src/main.rs @@ -7,6 +7,7 @@ use database::handlers::{ begin_transaction::{self, BeginTxReq}, commit_transaction::{self, CommitTxReq}, execute::{self, ExecuteReq}, + list_databases::{self, ListDatabasesReq}, prepare::{self, PrepareReq}, query::{self, QueryReq}, rollback_transaction::{self, RollbackTxReq}, @@ -101,6 +102,7 @@ async fn main() -> Result<()> { let log = Logger::new(); let state = AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(cfg)), handles: handles.clone(), transactions: transactions.clone(), log: log.clone(), @@ -265,6 +267,25 @@ async fn main() -> Result<()> { .description("Rollback and finalize an interactive transaction."), ); } + { + let st = state.clone(); + iii.register_function( + "database::listDatabases", + RegisterFunction::new_async(move |req: ListDatabasesReq| { + let st = st.clone(); + async move { + list_databases::handle(&st, req) + .await + .map_err(iii_sdk::IIIError::from) + } + }) + .description( + "List all configured databases with connection details (driver, \ + credential-redacted URL, pool settings, TLS mode). Config only — \ + no health checks or live pool statistics.", + ), + ); + } let _row_change = iii.register_trigger_type(RegisterTriggerType::new( "database::row-change", @@ -276,7 +297,7 @@ async fn main() -> Result<()> { .context("registering configuration change trigger")?; tracing::info!( - "database worker registered 11 functions and 1 trigger type, waiting for invocations" + "database worker registered 12 functions and 1 trigger type, waiting for invocations" ); wait_for_shutdown_signal().await?; tracing::info!("database worker shutting down"); diff --git a/database/tests/integration.rs b/database/tests/integration.rs index 76b5576e..c08d84f3 100644 --- a/database/tests/integration.rs +++ b/database/tests/integration.rs @@ -2,8 +2,10 @@ //! function handler end-to-end against an in-memory SQLite database. use database::config::WorkerConfig; +use database::configuration; use database::handle::HandleRegistry; use database::handlers::execute::ExecuteReq; +use database::handlers::list_databases::{self, ListDatabasesReq}; use database::handlers::prepare::PrepareReq; use database::handlers::query::QueryReq; use database::handlers::run_statement::RunReq; @@ -27,6 +29,7 @@ async fn build_state() -> AppState { } AppState { pools: Arc::new(RwLock::new(pools)), + config: Arc::new(RwLock::new(cfg)), handles: Arc::new(HandleRegistry::new()), transactions: TxRegistry::new(), log: Logger::new(), @@ -142,6 +145,43 @@ async fn end_to_end_query_execute_prepare_run_transaction() { assert_eq!(r.rows[1]["n"], 21); } +#[tokio::test(flavor = "multi_thread")] +async fn list_databases_reports_configured_primary() { + // Arrange + let st = build_state().await; + + // Act + let resp = list_databases::handle(&st, ListDatabasesReq::default()) + .await + .unwrap(); + + // Assert + assert_eq!(resp.count, 1); + assert_eq!(resp.databases[0].name, "primary"); + assert_eq!(resp.databases[0].driver, "sqlite"); + assert_eq!(resp.databases[0].url, "sqlite::memory:"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn apply_config_updates_list_snapshot() { + // Arrange + let st = build_state().await; + let new_yaml = + "databases:\n first:\n url: \"sqlite::memory:\"\n second:\n url: \"sqlite::memory:\"\n"; + let new_cfg = WorkerConfig::from_yaml(new_yaml).unwrap(); + + // Act + configuration::apply_config(&st, new_cfg).await.unwrap(); + let resp = list_databases::handle(&st, ListDatabasesReq::default()) + .await + .unwrap(); + + // Assert + assert_eq!(resp.count, 2); + let names: Vec<&str> = resp.databases.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["first", "second"]); +} + #[test] fn binary_name_matches_manifest() { assert_eq!(database::worker_name(), "database");