Skip to main content

featherbit/config/
resolve.rs

1//! Compile-time resolution of shared plugin configs (`config_ref`).
2//!
3//! [`resolve_plugin_configs`] validates the `plugin_configs` definitions and
4//! returns an in-memory copy of the gateway config where every node's
5//! `config_ref` — in policies and supernode definitions alike — has been
6//! materialized: effective config = shared config with the node's local keys
7//! written over it (shallow, top-level, local wins), `config_ref` cleared.
8//! Runs at the top of `SharedState::compile_routes` (before supernode
9//! expansion, so instances inherit resolved inner configs) and in the debug
10//! sandbox. The resolved copy is never stored: gateway.yaml, the Admin API,
11//! etcd, and the export endpoint always keep the reference form.
12
13use std::collections::HashMap;
14
15use serde_json::Value;
16
17use super::gateway::{GatewayConfig, NodeConfig, PluginConfigDef};
18
19/// Types that may not be used as a shared config's `type`: pipeline endpoints,
20/// supernode instances, and supernode boundary pseudo-nodes.
21const RESERVED_TYPES: [&str; 6] = [
22    "listener",
23    "client",
24    "supernode",
25    "input",
26    "output",
27    "error",
28];
29
30/// Validates shared plugin configs and materializes every `config_ref`.
31///
32/// Definition rules: unique names; `type` must be a factory-known plugin type
33/// and not a reserved type. Reference rules: the named def must exist and its
34/// type must equal the node's type; `supernode` instance nodes cannot carry a
35/// ref. All errors name the offending policy/supernode and node.
36pub fn resolve_plugin_configs(gw: &GatewayConfig) -> Result<GatewayConfig, String> {
37    // Consumed by SharedState::compile_routes (src/state.rs) and the debug sandbox.
38    let mut seen = std::collections::HashSet::new();
39    for def in &gw.plugin_configs {
40        if !seen.insert(def.name.as_str()) {
41            return Err(format!("Duplicate plugin config name '{}'", def.name));
42        }
43        if RESERVED_TYPES.contains(&def.plugin_type.as_str()) {
44            return Err(format!(
45                "Plugin config '{}' uses reserved type '{}'",
46                def.name, def.plugin_type
47            ));
48        }
49        if !crate::plugins::KNOWN_PLUGIN_TYPES.contains(&def.plugin_type.as_str()) {
50            return Err(format!(
51                "Plugin config '{}' references unknown plugin type '{}'",
52                def.name, def.plugin_type
53            ));
54        }
55    }
56
57    let by_name: HashMap<&str, &PluginConfigDef> = gw
58        .plugin_configs
59        .iter()
60        .map(|d| (d.name.as_str(), d))
61        .collect();
62
63    let mut out = gw.clone();
64    for policy in &mut out.policies {
65        let ctx = format!("policy '{}'", policy.name);
66        resolve_nodes(&mut policy.nodes, &by_name, &ctx)?;
67    }
68    for sn in &mut out.supernodes {
69        let ctx = format!("supernode '{}'", sn.name);
70        resolve_nodes(&mut sn.nodes, &by_name, &ctx)?;
71    }
72    Ok(out)
73}
74
75/// Materializes `config_ref` on each node in place: shared config first, then
76/// the node's own keys written over it (local wins), ref cleared.
77fn resolve_nodes(
78    nodes: &mut [NodeConfig],
79    by_name: &HashMap<&str, &PluginConfigDef>,
80    ctx: &str,
81) -> Result<(), String> {
82    for node in nodes {
83        let Some(ref_name) = node.config_ref.take() else {
84            continue;
85        };
86        if node.node_type == "supernode" {
87            return Err(format!(
88                "{ctx}: supernode instance node '{}' cannot use config_ref",
89                node.id
90            ));
91        }
92        let def = by_name.get(ref_name.as_str()).ok_or_else(|| {
93            format!(
94                "{ctx}: node '{}' references unknown plugin config '{}'",
95                node.id, ref_name
96            )
97        })?;
98        if def.plugin_type != node.node_type {
99            return Err(format!(
100                "{ctx}: node '{}' ({}) references plugin config '{}' of type '{}'",
101                node.id, node.node_type, ref_name, def.plugin_type
102            ));
103        }
104        let mut merged: HashMap<String, Value> = def.config.clone();
105        merged.extend(std::mem::take(&mut node.config));
106        node.config = merged;
107    }
108    Ok(())
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::config::{EdgeConfig, PolicyConfig, SupernodeConfig};
115    use serde_json::json;
116
117    fn def(name: &str, plugin_type: &str, config: serde_json::Value) -> PluginConfigDef {
118        PluginConfigDef {
119            name: name.into(),
120            plugin_type: plugin_type.into(),
121            description: None,
122            config: serde_json::from_value(config).unwrap(),
123        }
124    }
125
126    fn node(id: &str, ty: &str, config_ref: Option<&str>, config: serde_json::Value) -> NodeConfig {
127        NodeConfig {
128            id: id.into(),
129            node_type: ty.into(),
130            config: serde_json::from_value(config).unwrap(),
131            config_ref: config_ref.map(String::from),
132            position: None,
133        }
134    }
135
136    fn gw_with(policy_nodes: Vec<NodeConfig>, defs: Vec<PluginConfigDef>) -> GatewayConfig {
137        let mut gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
138        gw.plugin_configs = defs;
139        gw.policies = vec![PolicyConfig {
140            name: "p".into(),
141            error_handler: None,
142            nodes: policy_nodes,
143            edges: Vec::new(),
144        }];
145        gw
146    }
147
148    #[test]
149    fn test_merge_local_wins_and_ref_cleared() {
150        let gw = gw_with(
151            vec![node(
152                "auth",
153                "openid-connect",
154                Some("corp"),
155                json!({"scope": "openid profile"}),
156            )],
157            vec![def(
158                "corp",
159                "openid-connect",
160                json!({"client_id": "gw", "scope": "openid"}),
161            )],
162        );
163        let out = resolve_plugin_configs(&gw).unwrap();
164        let n = &out.policies[0].nodes[0];
165        assert_eq!(n.config["client_id"], json!("gw")); // inherited
166        assert_eq!(n.config["scope"], json!("openid profile")); // local wins
167        assert!(
168            n.config_ref.is_none(),
169            "ref must be cleared in resolved copy"
170        );
171        // Source is untouched (pure function).
172        assert_eq!(gw.policies[0].nodes[0].config_ref.as_deref(), Some("corp"));
173    }
174
175    #[test]
176    fn test_no_ref_passthrough_unchanged() {
177        let gw = gw_with(vec![node("a", "cors", None, json!({"x": 1}))], vec![]);
178        let out = resolve_plugin_configs(&gw).unwrap();
179        assert_eq!(out.policies[0].nodes[0].config["x"], json!(1));
180    }
181
182    #[test]
183    fn test_unknown_ref_is_error() {
184        let gw = gw_with(vec![node("a", "cors", Some("nope"), json!({}))], vec![]);
185        let err = resolve_plugin_configs(&gw).unwrap_err();
186        assert!(
187            err.contains("unknown plugin config 'nope'") && err.contains("'a'"),
188            "{err}"
189        );
190    }
191
192    #[test]
193    fn test_type_mismatch_is_error() {
194        let gw = gw_with(
195            vec![node("a", "cors", Some("corp"), json!({}))],
196            vec![def("corp", "openid-connect", json!({}))],
197        );
198        let err = resolve_plugin_configs(&gw).unwrap_err();
199        assert!(
200            err.contains("'corp'") && err.contains("openid-connect") && err.contains("cors"),
201            "{err}"
202        );
203    }
204
205    #[test]
206    fn test_supernode_definition_nodes_resolve() {
207        let mut gw = gw_with(
208            vec![],
209            vec![def("m", "mocking", json!({"response_status": 200}))],
210        );
211        gw.supernodes = vec![SupernodeConfig {
212            name: "sn".into(),
213            description: None,
214            nodes: vec![
215                node("input", "input", None, json!({})),
216                node("output", "output", None, json!({})),
217                node("error", "error", None, json!({})),
218                node(
219                    "mock",
220                    "mocking",
221                    Some("m"),
222                    json!({"content_type": "text/plain"}),
223                ),
224            ],
225            edges: vec![
226                EdgeConfig {
227                    from: "input.out".into(),
228                    to: "mock.in".into(),
229                },
230                EdgeConfig {
231                    from: "mock.success".into(),
232                    to: "output.in".into(),
233                },
234            ],
235        }];
236        let out = resolve_plugin_configs(&gw).unwrap();
237        let inner = &out.supernodes[0].nodes[3];
238        assert_eq!(inner.config["response_status"], json!(200));
239        assert_eq!(inner.config["content_type"], json!("text/plain"));
240        assert!(inner.config_ref.is_none());
241    }
242
243    #[test]
244    fn test_supernode_instance_node_cannot_carry_ref() {
245        let gw = gw_with(
246            vec![node("sn", "supernode", Some("m"), json!({"name": "x"}))],
247            vec![def("m", "mocking", json!({}))],
248        );
249        let err = resolve_plugin_configs(&gw).unwrap_err();
250        assert!(
251            err.contains("supernode") && err.contains("config_ref"),
252            "{err}"
253        );
254    }
255
256    #[test]
257    fn test_duplicate_def_names_rejected() {
258        let gw = gw_with(
259            vec![],
260            vec![def("d", "cors", json!({})), def("d", "cors", json!({}))],
261        );
262        let err = resolve_plugin_configs(&gw).unwrap_err();
263        assert!(err.contains("Duplicate plugin config name 'd'"), "{err}");
264    }
265
266    #[test]
267    fn test_unknown_and_reserved_def_types_rejected() {
268        let gw = gw_with(vec![], vec![def("d", "openid-conect", json!({}))]);
269        let err = resolve_plugin_configs(&gw).unwrap_err();
270        assert!(err.contains("unknown plugin type 'openid-conect'"), "{err}");
271
272        for ty in RESERVED_TYPES {
273            let gw = gw_with(vec![], vec![def("d", ty, json!({}))]);
274            let err = resolve_plugin_configs(&gw).unwrap_err();
275            assert!(err.contains("reserved"), "type {ty}: {err}");
276        }
277    }
278}