platform/config/
config_store.rs1use super::registry;
7use anyhow::{Result, bail};
8use serde::Serialize;
9use sqlx::sqlite::SqlitePool;
10
11#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
13pub struct ConfigOverride {
14 pub key_path: String,
15 pub value: String,
16 pub updated_at: String,
17 pub updated_by: String,
18}
19
20#[derive(Clone, Debug)]
22pub struct ConfigOverrideStore {
23 pool: SqlitePool,
24}
25
26impl ConfigOverrideStore {
27 pub async fn new(pool: SqlitePool) -> Result<Self> {
29 sqlx::query(
30 "CREATE TABLE IF NOT EXISTS config_overrides (
31 key_path TEXT PRIMARY KEY,
32 value TEXT NOT NULL,
33 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
34 updated_by TEXT NOT NULL DEFAULT 'admin'
35 )",
36 )
37 .execute(&pool)
38 .await?;
39
40 Ok(Self { pool })
41 }
42
43 pub async fn get(&self, key: &str) -> Result<Option<ConfigOverride>> {
45 let row = sqlx::query_as::<_, ConfigOverride>(
46 "SELECT key_path, value, updated_at, updated_by FROM config_overrides WHERE key_path = ?",
47 )
48 .bind(key)
49 .fetch_optional(&self.pool)
50 .await?;
51
52 Ok(row)
53 }
54
55 pub async fn set(&self, key: &str, value: &str, by: &str) -> Result<()> {
57 let field =
58 registry::get_field(key).ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}"))?;
59
60 if !field.dynamic {
61 bail!(
62 "Config key '{}' is not dynamic and cannot be overridden at runtime",
63 key
64 );
65 }
66
67 if !field.validate(value) {
68 bail!(
69 "Invalid value '{}' for config key '{}' (expected type: {}{})",
70 value,
71 key,
72 field.value_type,
73 if field.choices.is_empty() {
74 String::new()
75 } else {
76 format!(", choices: [{}]", field.choices.join(", "))
77 }
78 );
79 }
80
81 sqlx::query(
82 "INSERT INTO config_overrides (key_path, value, updated_at, updated_by)
83 VALUES (?, ?, datetime('now'), ?)
84 ON CONFLICT(key_path) DO UPDATE SET
85 value = excluded.value,
86 updated_at = excluded.updated_at,
87 updated_by = excluded.updated_by",
88 )
89 .bind(key)
90 .bind(value)
91 .bind(by)
92 .execute(&self.pool)
93 .await?;
94
95 Ok(())
96 }
97
98 pub async fn delete(&self, key: &str) -> Result<bool> {
100 let result = sqlx::query("DELETE FROM config_overrides WHERE key_path = ?")
101 .bind(key)
102 .execute(&self.pool)
103 .await?;
104
105 Ok(result.rows_affected() > 0)
106 }
107
108 pub async fn list_all(&self) -> Result<Vec<ConfigOverride>> {
110 let rows = sqlx::query_as::<_, ConfigOverride>(
111 "SELECT key_path, value, updated_at, updated_by FROM config_overrides ORDER BY key_path",
112 )
113 .fetch_all(&self.pool)
114 .await?;
115
116 Ok(rows)
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
124 use std::str::FromStr;
125
126 async fn test_pool() -> SqlitePool {
127 let options = SqliteConnectOptions::from_str("sqlite::memory:")
128 .unwrap()
129 .create_if_missing(true);
130 SqlitePoolOptions::new()
131 .max_connections(1)
132 .connect_with(options)
133 .await
134 .unwrap()
135 }
136
137 #[tokio::test]
138 async fn test_crud() {
139 let pool = test_pool().await;
140 let store = ConfigOverrideStore::new(pool).await.unwrap();
141
142 assert!(store.list_all().await.unwrap().is_empty());
144
145 store
147 .set("turn.realm", "test.local", "admin")
148 .await
149 .unwrap();
150
151 let entry = store.get("turn.realm").await.unwrap().unwrap();
153 assert_eq!(entry.value, "test.local");
154 assert_eq!(entry.updated_by, "admin");
155
156 store
158 .set("turn.realm", "updated.local", "admin")
159 .await
160 .unwrap();
161 let entry = store.get("turn.realm").await.unwrap().unwrap();
162 assert_eq!(entry.value, "updated.local");
163
164 let all = store.list_all().await.unwrap();
166 assert_eq!(all.len(), 1);
167
168 assert!(store.delete("turn.realm").await.unwrap());
170 assert!(store.get("turn.realm").await.unwrap().is_none());
171 assert!(!store.delete("turn.realm").await.unwrap());
172 }
173
174 #[tokio::test]
175 async fn test_rejects_non_dynamic() {
176 let pool = test_pool().await;
177 let store = ConfigOverrideStore::new(pool).await.unwrap();
178
179 let result = store.set("bind.ice.ip", "1.2.3.4", "admin").await;
180 assert!(result.is_err());
181 assert!(result.unwrap_err().to_string().contains("not dynamic"));
182 }
183
184 #[tokio::test]
185 async fn test_rejects_invalid_type() {
186 let pool = test_pool().await;
187 let store = ConfigOverrideStore::new(pool).await.unwrap();
188
189 let result = store
190 .set(
191 "services.signaling.server.rate_limit.connection.per_minute",
192 "not_a_number",
193 "admin",
194 )
195 .await;
196 assert!(result.is_err());
197 assert!(result.unwrap_err().to_string().contains("Invalid value"));
198 }
199
200 #[tokio::test]
201 async fn test_rejects_unknown_key() {
202 let pool = test_pool().await;
203 let store = ConfigOverrideStore::new(pool).await.unwrap();
204
205 let result = store.set("nonexistent.key", "value", "admin").await;
206 assert!(result.is_err());
207 assert!(
208 result
209 .unwrap_err()
210 .to_string()
211 .contains("Unknown config key")
212 );
213 }
214}