Skip to main content

featherbit/config_store/
mod.rs

1//! Configuration source abstraction.
2//!
3//! Decides where the gateway's routes/policies/consumers are loaded from and
4//! how Admin API mutations are persisted. [`FileConfigStore`] is the default,
5//! stateless source (reads `gateway.yaml`, applies edits in memory). The
6//! `etcd` feature adds [`EtcdConfigStore`] for shared config delivery across an
7//! HA cluster — every instance watches the same etcd prefix and converges on
8//! changes. Both sit behind the [`ConfigStore`] trait so `main` and the Admin
9//! API are backend-agnostic.
10
11use async_trait::async_trait;
12use std::path::PathBuf;
13
14use crate::config::{load_yaml, GatewayConfig};
15use crate::state::SharedState;
16
17pub mod etcd;
18
19/// A source of gateway configuration and the sink for Admin API mutations.
20#[async_trait]
21pub trait ConfigStore: Send + Sync {
22    /// Loads the full gateway config from the source.
23    async fn load_all(&self) -> Result<GatewayConfig, String>;
24
25    /// Persists a candidate config (the full desired `GatewayConfig` after an
26    /// Admin API mutation) and makes it live.
27    ///
28    /// Implementations must **reject invalid config** — via
29    /// [`SharedState::validate_gateway`] — before persisting, so the Admin API
30    /// returns an error synchronously rather than accepting a config that would
31    /// fail to compile.
32    async fn commit(&self, state: &SharedState, candidate: GatewayConfig) -> Result<(), String>;
33}
34
35/// File-backed config store: loads `gateway.yaml` and applies Admin mutations
36/// in memory. The on-disk file is **not** rewritten (matching the original
37/// behavior — edits are live but not persisted to the file). Stateless,
38/// single-node; the default when `config.source` is `file` or unset.
39pub struct FileConfigStore {
40    path: PathBuf,
41}
42
43impl FileConfigStore {
44    /// Creates a file store reading from `path` (`gateway.yaml`).
45    pub fn new(path: PathBuf) -> Self {
46        Self { path }
47    }
48}
49
50#[async_trait]
51impl ConfigStore for FileConfigStore {
52    async fn load_all(&self) -> Result<GatewayConfig, String> {
53        // Raw load: `${VAR}` placeholders stay in the stored config (the
54        // Admin API serves it to the UI); env resolution happens at
55        // compile/build time.
56        load_yaml(&self.path).map_err(|e| e.to_string())
57    }
58
59    async fn commit(&self, state: &SharedState, candidate: GatewayConfig) -> Result<(), String> {
60        // apply_gateway validates + compiles before swapping, so an invalid
61        // candidate is rejected here with the running config left intact.
62        state.apply_gateway(candidate).await
63    }
64}
65
66#[cfg(test)]
67pub mod tests {
68    use super::*;
69    use std::sync::Mutex;
70
71    /// In-memory config store for tests: `load_all` returns a preset config,
72    /// `commit` validates the candidate and records it without touching real
73    /// state, so the commit/validation path is testable without a file or etcd.
74    pub struct FakeConfigStore {
75        pub initial: GatewayConfig,
76        pub committed: Mutex<Vec<GatewayConfig>>,
77    }
78
79    impl FakeConfigStore {
80        pub fn new(initial: GatewayConfig) -> Self {
81            Self {
82                initial,
83                committed: Mutex::new(Vec::new()),
84            }
85        }
86    }
87
88    #[async_trait]
89    impl ConfigStore for FakeConfigStore {
90        async fn load_all(&self) -> Result<GatewayConfig, String> {
91            Ok(self.initial.clone())
92        }
93
94        async fn commit(
95            &self,
96            state: &SharedState,
97            candidate: GatewayConfig,
98        ) -> Result<(), String> {
99            // Reject invalid config exactly as a real store must.
100            state.validate_gateway(&candidate)?;
101            self.committed.lock().unwrap().push(candidate);
102            Ok(())
103        }
104    }
105
106    use std::sync::Arc;
107
108    fn empty_state() -> Arc<SharedState> {
109        let system = serde_yaml::from_str("{}").unwrap();
110        let gateway = serde_yaml::from_str("{}").unwrap();
111        Arc::new(
112            SharedState::new(
113                system,
114                gateway,
115                None,
116                Arc::new(FakeConfigStore::new(serde_yaml::from_str("{}").unwrap())),
117            )
118            .unwrap(),
119        )
120    }
121
122    const VALID_GW: &str = r#"
123routes:
124  - name: r
125    match: { path: /api/* }
126    policy: p
127policies:
128  - name: p
129    nodes:
130      - { id: listener, type: listener }
131      - { id: client, type: client }
132    edges:
133      - { from: listener.out, to: client.in }
134"#;
135
136    #[tokio::test]
137    async fn test_load_all_preserves_env_placeholders() {
138        // The loaded config is what the Admin API serves to the Web UI: a
139        // `${VAR}` written in gateway.yaml must survive loading as the
140        // literal placeholder — env resolution happens at graph-compile
141        // time — so secret values never leak into API responses or UI
142        // exports of the config.
143        std::env::set_var("TEST_STORE_SECRET", "actual-secret-value");
144        let path = std::env::temp_dir().join(format!("fb_store_raw_{}.yaml", std::process::id()));
145        std::fs::write(
146            &path,
147            r#"
148policies:
149  - name: p
150    nodes:
151      - id: auth
152        type: openid-connect
153        config:
154          client_secret: ${TEST_STORE_SECRET}
155"#,
156        )
157        .unwrap();
158
159        let gw = FileConfigStore::new(path.clone()).load_all().await.unwrap();
160        assert_eq!(
161            gw.policies[0].nodes[0].config["client_secret"],
162            serde_json::json!("${TEST_STORE_SECRET}")
163        );
164
165        std::fs::remove_file(&path).ok();
166        std::env::remove_var("TEST_STORE_SECRET");
167    }
168
169    #[tokio::test]
170    async fn test_file_commit_applies_valid_config() {
171        let state = empty_state();
172        // Swap in a real file store so commit exercises apply_gateway.
173        let store = FileConfigStore::new("unused.yaml".into());
174        let candidate: GatewayConfig = serde_yaml::from_str(VALID_GW).unwrap();
175
176        store.commit(&state, candidate).await.unwrap();
177        assert_eq!(state.routes.read().await.len(), 1);
178        assert_eq!(state.gateway.read().await.routes[0].name, "r");
179    }
180
181    #[tokio::test]
182    async fn test_commit_rejects_invalid_config() {
183        let state = empty_state();
184        // Route references a policy that does not exist.
185        let bad: GatewayConfig = serde_yaml::from_str(
186            "routes:\n  - name: r\n    match: { path: /x }\n    policy: missing\n",
187        )
188        .unwrap();
189
190        // Both the file store and the fake store must reject it before persisting.
191        assert!(FileConfigStore::new("unused.yaml".into())
192            .commit(&state, bad.clone())
193            .await
194            .is_err());
195
196        let fake = FakeConfigStore::new(serde_yaml::from_str("{}").unwrap());
197        assert!(fake.commit(&state, bad).await.is_err());
198        assert!(fake.committed.lock().unwrap().is_empty());
199        // The running config was never touched.
200        assert_eq!(state.routes.read().await.len(), 0);
201    }
202}