Skip to main content

greentic_start/notifier/
config.rs

1//! Resolve a `NotifierConfig` for boot, including auto-detect of the
2//! state-redis URL when `backend: redis` is selected without an explicit URL.
3
4use 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
14/// Resolve the effective notifier configuration.
15///
16/// - `Memory` and `Redis { url: Some(_) }` pass through unchanged.
17/// - `Redis { url: None }` triggers auto-detect from the state-redis
18///   provider's `ConfigEnvelope`, with secret URI resolution if the URL
19///   field is a `secret://` reference.
20pub 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/// Indirection so unit tests can inject a fake without depending on the full
69/// secrets manager construction.
70#[async_trait::async_trait]
71pub trait SecretResolver: Send + Sync {
72    /// If `raw` is a literal URL, return it as-is. If it's a `secret://` URI,
73    /// resolve to the underlying value.
74    async fn resolve(&self, raw: &str) -> Result<String>;
75}
76
77/// Production adapter that wraps `Arc<dyn SecretsManager>` so the notifier
78/// auto-detect path can resolve `secret://` URIs without requiring callers
79/// to know the full secrets-manager surface.
80pub 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        // Accept BOTH the legacy singular `secret://` scheme and the canonical
88        // plural `secrets://` scheme. B12a writes `secrets://` URI refs into
89        // the config envelope (e.g. state-redis `url`); a bare
90        // `starts_with("secret://")` check would NOT match `secrets://` (they
91        // differ at index 6) and would return the URI verbatim as a literal
92        // connection string.
93        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        // Maps `secret://...` URIs to literal values; literal `redis://...`
116        // is returned as-is.
117        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        // Note: no envelope written — auto-detect would fail if it ran.
184        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}