greentic_start/notifier/
config.rs1use std::path::Path;
5use std::sync::Arc;
6
7use anyhow::{Context, Result, anyhow};
8use greentic_secrets_lib::SecretsManager;
9
10use crate::config::OperatorConfig;
11use crate::notifier::NotifierConfig;
12use crate::provider_config_envelope::{ConfigEnvelope, require_provider_config_envelope};
13
14pub async fn resolve_notifier_config(
21 operator_root: &Path,
22 operator_config: &OperatorConfig,
23 secret_resolver: &dyn SecretResolver,
24) -> Result<NotifierConfig> {
25 let raw = operator_config
26 .webchat
27 .as_ref()
28 .map(|w| w.notifier.clone())
29 .unwrap_or_default();
30
31 match raw {
32 NotifierConfig::Memory { .. } => Ok(raw),
33 NotifierConfig::Redis { url: Some(_), .. } => Ok(raw),
34 NotifierConfig::Redis {
35 url: None,
36 channel,
37 capacity,
38 } => {
39 let providers_root = operator_root.join("providers");
40 let envelope: ConfigEnvelope =
41 require_provider_config_envelope(&providers_root, "state-redis").with_context(
42 || {
43 "Redis notifier backend selected but the state-redis provider is not \
44 configured. Run `gtc setup --provider state-redis` first, or set \
45 webchat.notifier.url explicitly in greentic.yaml."
46 },
47 )?;
48 let url_field = envelope
49 .config
50 .get("url")
51 .and_then(|v| v.as_str())
52 .ok_or_else(|| {
53 anyhow!("state-redis ConfigEnvelope missing required `url` field")
54 })?;
55 let resolved_url = secret_resolver
56 .resolve(url_field)
57 .await
58 .context("failed to resolve state-redis url secret reference")?;
59 Ok(NotifierConfig::Redis {
60 url: Some(resolved_url),
61 channel,
62 capacity,
63 })
64 }
65 }
66}
67
68#[async_trait::async_trait]
71pub trait SecretResolver: Send + Sync {
72 async fn resolve(&self, raw: &str) -> Result<String>;
75}
76
77pub struct SecretsManagerResolver {
81 pub manager: Arc<dyn SecretsManager>,
82}
83
84#[async_trait::async_trait]
85impl SecretResolver for SecretsManagerResolver {
86 async fn resolve(&self, raw: &str) -> Result<String> {
87 if !raw.starts_with("secret://") && !raw.starts_with("secrets://") {
94 return Ok(raw.to_string());
95 }
96 let bytes = self
97 .manager
98 .read(raw)
99 .await
100 .map_err(|err| anyhow!("resolve secret URI {raw}: {err}"))?;
101 String::from_utf8(bytes).with_context(|| format!("secret {raw} is not valid UTF-8"))
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::config::WebchatConfig;
109 use crate::provider_config_envelope::ConfigEnvelope;
110 use serde_json::json;
111 use std::sync::Mutex;
112 use tempfile::tempdir;
113
114 struct FakeResolver {
115 map: Mutex<std::collections::HashMap<String, String>>,
118 }
119 impl FakeResolver {
120 fn new() -> Self {
121 Self {
122 map: Mutex::new(Default::default()),
123 }
124 }
125 fn with(secret: &str, literal: &str) -> Self {
126 let r = Self::new();
127 r.map.lock().unwrap().insert(secret.into(), literal.into());
128 r
129 }
130 }
131 #[async_trait::async_trait]
132 impl SecretResolver for FakeResolver {
133 async fn resolve(&self, raw: &str) -> Result<String> {
134 if raw.starts_with("secret://") {
135 self.map
136 .lock()
137 .unwrap()
138 .get(raw)
139 .cloned()
140 .ok_or_else(|| anyhow!("no fake mapping for {raw}"))
141 } else {
142 Ok(raw.to_string())
143 }
144 }
145 }
146
147 fn op_with_redis(url: Option<&str>) -> OperatorConfig {
148 OperatorConfig {
149 webchat: Some(WebchatConfig {
150 notifier: NotifierConfig::Redis {
151 url: url.map(String::from),
152 channel: None,
153 capacity: 64,
154 },
155 }),
156 ..Default::default()
157 }
158 }
159
160 fn write_state_redis_envelope(operator_root: &std::path::Path, url_field: &str) {
161 let providers_root = operator_root.join("providers");
162 let path = providers_root
163 .join("state-redis")
164 .join("config.envelope.cbor");
165 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
166 let env = ConfigEnvelope {
167 config: json!({"url": url_field}),
168 component_id: "state-redis".into(),
169 abi_version: crate::provider_config_envelope::ABI_VERSION.to_string(),
170 resolved_digest: "sha256:0".into(),
171 describe_hash: "h".into(),
172 schema_hash: None,
173 operation_id: "configure".into(),
174 updated_at: None,
175 };
176 let bytes = greentic_types::cbor::canonical::to_canonical_cbor(&env).unwrap();
177 std::fs::write(&path, bytes).unwrap();
178 }
179
180 #[tokio::test]
181 async fn explicit_url_skips_autodetect() {
182 let dir = tempdir().unwrap();
183 let op = op_with_redis(Some("redis://override:1"));
185 let resolved = resolve_notifier_config(dir.path(), &op, &FakeResolver::new())
186 .await
187 .unwrap();
188 match resolved {
189 NotifierConfig::Redis { url, .. } => {
190 assert_eq!(url.as_deref(), Some("redis://override:1"))
191 }
192 _ => panic!("expected Redis variant"),
193 }
194 }
195
196 #[tokio::test]
197 async fn autodetect_missing_state_redis_errors() {
198 let dir = tempdir().unwrap();
199 let op = op_with_redis(None);
200 let err = resolve_notifier_config(dir.path(), &op, &FakeResolver::new())
201 .await
202 .unwrap_err();
203 let msg = format!("{err:#}");
204 assert!(
205 msg.contains("state-redis"),
206 "error must mention state-redis: {msg}"
207 );
208 }
209
210 #[tokio::test]
211 async fn autodetect_uses_literal_url_from_envelope() {
212 let dir = tempdir().unwrap();
213 write_state_redis_envelope(dir.path(), "redis://envelope:6379");
214 let op = op_with_redis(None);
215 let resolved = resolve_notifier_config(dir.path(), &op, &FakeResolver::new())
216 .await
217 .unwrap();
218 match resolved {
219 NotifierConfig::Redis { url, .. } => {
220 assert_eq!(url.as_deref(), Some("redis://envelope:6379"))
221 }
222 _ => panic!("expected Redis variant"),
223 }
224 }
225
226 #[tokio::test]
227 async fn autodetect_resolves_secret_uri() {
228 let dir = tempdir().unwrap();
229 write_state_redis_envelope(dir.path(), "secret://state-redis/url");
230 let op = op_with_redis(None);
231 let resolver = FakeResolver::with("secret://state-redis/url", "redis://resolved:6379");
232 let resolved = resolve_notifier_config(dir.path(), &op, &resolver)
233 .await
234 .unwrap();
235 match resolved {
236 NotifierConfig::Redis { url, .. } => {
237 assert_eq!(url.as_deref(), Some("redis://resolved:6379"))
238 }
239 _ => panic!("expected Redis variant"),
240 }
241 }
242
243 #[tokio::test]
244 async fn memory_backend_passes_through() {
245 let dir = tempdir().unwrap();
246 let op = OperatorConfig::default();
247 let resolved = resolve_notifier_config(dir.path(), &op, &FakeResolver::new())
248 .await
249 .unwrap();
250 assert!(matches!(resolved, NotifierConfig::Memory { .. }));
251 }
252}