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_with_env, 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        load_yaml_with_env(&self.path).map_err(|e| e.to_string())
54    }
55
56    async fn commit(&self, state: &SharedState, candidate: GatewayConfig) -> Result<(), String> {
57        // apply_gateway validates + compiles before swapping, so an invalid
58        // candidate is rejected here with the running config left intact.
59        state.apply_gateway(candidate).await
60    }
61}
62
63#[cfg(test)]
64pub mod tests {
65    use super::*;
66    use std::sync::Mutex;
67
68    /// In-memory config store for tests: `load_all` returns a preset config,
69    /// `commit` validates the candidate and records it without touching real
70    /// state, so the commit/validation path is testable without a file or etcd.
71    pub struct FakeConfigStore {
72        pub initial: GatewayConfig,
73        pub committed: Mutex<Vec<GatewayConfig>>,
74    }
75
76    impl FakeConfigStore {
77        pub fn new(initial: GatewayConfig) -> Self {
78            Self {
79                initial,
80                committed: Mutex::new(Vec::new()),
81            }
82        }
83    }
84
85    #[async_trait]
86    impl ConfigStore for FakeConfigStore {
87        async fn load_all(&self) -> Result<GatewayConfig, String> {
88            Ok(self.initial.clone())
89        }
90
91        async fn commit(
92            &self,
93            state: &SharedState,
94            candidate: GatewayConfig,
95        ) -> Result<(), String> {
96            // Reject invalid config exactly as a real store must.
97            state.validate_gateway(&candidate)?;
98            self.committed.lock().unwrap().push(candidate);
99            Ok(())
100        }
101    }
102
103    use std::sync::Arc;
104
105    fn empty_state() -> Arc<SharedState> {
106        let system = serde_yaml::from_str("{}").unwrap();
107        let gateway = serde_yaml::from_str("{}").unwrap();
108        Arc::new(
109            SharedState::new(
110                system,
111                gateway,
112                None,
113                Arc::new(FakeConfigStore::new(serde_yaml::from_str("{}").unwrap())),
114            )
115            .unwrap(),
116        )
117    }
118
119    const VALID_GW: &str = r#"
120routes:
121  - name: r
122    match: { path: /api/* }
123    policy: p
124policies:
125  - name: p
126    nodes:
127      - { id: listener, type: listener }
128      - { id: client, type: client }
129    edges:
130      - { from: listener.out, to: client.in }
131"#;
132
133    #[tokio::test]
134    async fn test_file_commit_applies_valid_config() {
135        let state = empty_state();
136        // Swap in a real file store so commit exercises apply_gateway.
137        let store = FileConfigStore::new("unused.yaml".into());
138        let candidate: GatewayConfig = serde_yaml::from_str(VALID_GW).unwrap();
139
140        store.commit(&state, candidate).await.unwrap();
141        assert_eq!(state.routes.read().await.len(), 1);
142        assert_eq!(state.gateway.read().await.routes[0].name, "r");
143    }
144
145    #[tokio::test]
146    async fn test_commit_rejects_invalid_config() {
147        let state = empty_state();
148        // Route references a policy that does not exist.
149        let bad: GatewayConfig = serde_yaml::from_str(
150            "routes:\n  - name: r\n    match: { path: /x }\n    policy: missing\n",
151        )
152        .unwrap();
153
154        // Both the file store and the fake store must reject it before persisting.
155        assert!(FileConfigStore::new("unused.yaml".into())
156            .commit(&state, bad.clone())
157            .await
158            .is_err());
159
160        let fake = FakeConfigStore::new(serde_yaml::from_str("{}").unwrap());
161        assert!(fake.commit(&state, bad).await.is_err());
162        assert!(fake.committed.lock().unwrap().is_empty());
163        // The running config was never touched.
164        assert_eq!(state.routes.read().await.len(), 0);
165    }
166}