sentinel_config/
flatten.rs

1//! Flattened configuration for runtime consumption.
2//!
3//! This module provides [`FlattenedConfig`] which transforms the hierarchical
4//! namespace/service configuration into a flat structure suitable for runtime
5//! lookups with qualified IDs.
6//!
7//! # Why Flatten?
8//!
9//! The hierarchical configuration is great for authoring (domain-driven organization),
10//! but at runtime we need fast lookups by qualified ID. Flattening:
11//!
12//! 1. Pre-computes qualified IDs for all resources
13//! 2. Collects scope-specific limits for runtime isolation
14//! 3. Enables O(1) lookups via HashMap
15
16use sentinel_common::ids::{QualifiedId, Scope};
17use sentinel_common::limits::Limits;
18use std::collections::HashMap;
19
20use crate::{
21    AgentConfig, Config, FilterConfig, ListenerConfig, RouteConfig, UpstreamConfig,
22};
23
24// ============================================================================
25// Flattened Configuration
26// ============================================================================
27
28/// Flattened configuration with all resources indexed by qualified IDs.
29///
30/// This structure is produced by [`Config::flatten()`] and provides efficient
31/// runtime lookups for all resource types.
32#[derive(Debug, Clone)]
33pub struct FlattenedConfig {
34    /// All upstreams indexed by their qualified ID
35    pub upstreams: HashMap<QualifiedId, UpstreamConfig>,
36
37    /// All routes with their qualified IDs
38    pub routes: Vec<(QualifiedId, RouteConfig)>,
39
40    /// All agents indexed by their qualified ID
41    pub agents: HashMap<QualifiedId, AgentConfig>,
42
43    /// All filters indexed by their qualified ID
44    pub filters: HashMap<QualifiedId, FilterConfig>,
45
46    /// All listeners with their qualified IDs
47    pub listeners: Vec<(QualifiedId, ListenerConfig)>,
48
49    /// Limits per scope for runtime isolation
50    pub scope_limits: HashMap<Scope, Limits>,
51
52    /// Exported upstream names (for fast lookup)
53    pub exported_upstreams: HashMap<String, QualifiedId>,
54
55    /// Exported agent names (for fast lookup)
56    pub exported_agents: HashMap<String, QualifiedId>,
57
58    /// Exported filter names (for fast lookup)
59    pub exported_filters: HashMap<String, QualifiedId>,
60}
61
62impl FlattenedConfig {
63    /// Create a new empty flattened config.
64    pub fn new() -> Self {
65        Self {
66            upstreams: HashMap::new(),
67            routes: Vec::new(),
68            agents: HashMap::new(),
69            filters: HashMap::new(),
70            listeners: Vec::new(),
71            scope_limits: HashMap::new(),
72            exported_upstreams: HashMap::new(),
73            exported_agents: HashMap::new(),
74            exported_filters: HashMap::new(),
75        }
76    }
77
78    /// Get an upstream by its qualified ID.
79    pub fn get_upstream(&self, qid: &QualifiedId) -> Option<&UpstreamConfig> {
80        self.upstreams.get(qid)
81    }
82
83    /// Get an upstream by its canonical string form.
84    pub fn get_upstream_by_canonical(&self, canonical: &str) -> Option<&UpstreamConfig> {
85        self.upstreams.get(&QualifiedId::parse(canonical))
86    }
87
88    /// Get an agent by its qualified ID.
89    pub fn get_agent(&self, qid: &QualifiedId) -> Option<&AgentConfig> {
90        self.agents.get(qid)
91    }
92
93    /// Get a filter by its qualified ID.
94    pub fn get_filter(&self, qid: &QualifiedId) -> Option<&FilterConfig> {
95        self.filters.get(qid)
96    }
97
98    /// Get limits for a specific scope.
99    ///
100    /// Returns the limits for the most specific scope that has limits defined.
101    /// If no limits are defined for the scope, returns None.
102    pub fn get_limits(&self, scope: &Scope) -> Option<&Limits> {
103        self.scope_limits.get(scope)
104    }
105
106    /// Get effective limits for a scope, falling back through the scope chain.
107    ///
108    /// Searches from the given scope up through parent scopes until limits are found.
109    pub fn get_effective_limits(&self, scope: &Scope) -> Option<&Limits> {
110        for s in scope.chain() {
111            if let Some(limits) = self.scope_limits.get(&s) {
112                return Some(limits);
113            }
114        }
115        None
116    }
117
118    /// Get all routes in a specific scope.
119    pub fn routes_in_scope<'a>(&'a self, scope: &'a Scope) -> impl Iterator<Item = &'a (QualifiedId, RouteConfig)> {
120        self.routes.iter().filter(move |(qid, _)| &qid.scope == scope)
121    }
122
123    /// Get all listeners in a specific scope.
124    pub fn listeners_in_scope<'a>(&'a self, scope: &'a Scope) -> impl Iterator<Item = &'a (QualifiedId, ListenerConfig)> {
125        self.listeners.iter().filter(move |(qid, _)| &qid.scope == scope)
126    }
127
128    /// Check if an upstream name is exported.
129    pub fn is_upstream_exported(&self, name: &str) -> bool {
130        self.exported_upstreams.contains_key(name)
131    }
132
133    /// Get the qualified ID of an exported upstream by its local name.
134    pub fn get_exported_upstream_qid(&self, name: &str) -> Option<&QualifiedId> {
135        self.exported_upstreams.get(name)
136    }
137}
138
139impl Default for FlattenedConfig {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145// ============================================================================
146// Config Flattening Implementation
147// ============================================================================
148
149impl Config {
150    /// Flatten the hierarchical configuration into a runtime-friendly structure.
151    ///
152    /// This converts all namespace/service resources into qualified IDs and
153    /// collects them into flat HashMaps for efficient lookup.
154    pub fn flatten(&self) -> FlattenedConfig {
155        let mut flat = FlattenedConfig::new();
156
157        // Add global limits
158        flat.scope_limits.insert(Scope::Global, self.limits.clone());
159
160        // Flatten global resources
161        self.flatten_global(&mut flat);
162
163        // Flatten namespaces
164        for ns in &self.namespaces {
165            self.flatten_namespace(ns, &mut flat);
166        }
167
168        flat
169    }
170
171    fn flatten_global(&self, flat: &mut FlattenedConfig) {
172        // Global upstreams
173        for (id, upstream) in &self.upstreams {
174            flat.upstreams.insert(QualifiedId::global(id), upstream.clone());
175        }
176
177        // Global routes
178        for route in &self.routes {
179            flat.routes.push((QualifiedId::global(&route.id), route.clone()));
180        }
181
182        // Global agents
183        for agent in &self.agents {
184            flat.agents.insert(QualifiedId::global(&agent.id), agent.clone());
185        }
186
187        // Global filters
188        for (id, filter) in &self.filters {
189            flat.filters.insert(QualifiedId::global(id), filter.clone());
190        }
191
192        // Global listeners
193        for listener in &self.listeners {
194            flat.listeners.push((QualifiedId::global(&listener.id), listener.clone()));
195        }
196    }
197
198    fn flatten_namespace(&self, ns: &crate::NamespaceConfig, flat: &mut FlattenedConfig) {
199        let ns_scope = Scope::Namespace(ns.id.clone());
200
201        // Namespace limits (if defined)
202        if let Some(ref limits) = ns.limits {
203            flat.scope_limits.insert(ns_scope.clone(), limits.clone());
204        }
205
206        // Namespace upstreams
207        for (id, upstream) in &ns.upstreams {
208            let qid = QualifiedId::namespaced(&ns.id, id);
209            flat.upstreams.insert(qid.clone(), upstream.clone());
210
211            // Track exports
212            if ns.exports.upstreams.contains(id) {
213                flat.exported_upstreams.insert(id.clone(), qid);
214            }
215        }
216
217        // Namespace routes
218        for route in &ns.routes {
219            flat.routes.push((
220                QualifiedId::namespaced(&ns.id, &route.id),
221                route.clone(),
222            ));
223        }
224
225        // Namespace agents
226        for agent in &ns.agents {
227            let qid = QualifiedId::namespaced(&ns.id, &agent.id);
228            flat.agents.insert(qid.clone(), agent.clone());
229
230            // Track exports
231            if ns.exports.agents.contains(&agent.id) {
232                flat.exported_agents.insert(agent.id.clone(), qid);
233            }
234        }
235
236        // Namespace filters
237        for (id, filter) in &ns.filters {
238            let qid = QualifiedId::namespaced(&ns.id, id);
239            flat.filters.insert(qid.clone(), filter.clone());
240
241            // Track exports
242            if ns.exports.filters.contains(id) {
243                flat.exported_filters.insert(id.clone(), qid);
244            }
245        }
246
247        // Namespace listeners
248        for listener in &ns.listeners {
249            flat.listeners.push((
250                QualifiedId::namespaced(&ns.id, &listener.id),
251                listener.clone(),
252            ));
253        }
254
255        // Flatten services within namespace
256        for svc in &ns.services {
257            self.flatten_service(&ns.id, svc, flat);
258        }
259    }
260
261    fn flatten_service(
262        &self,
263        ns_id: &str,
264        svc: &crate::ServiceConfig,
265        flat: &mut FlattenedConfig,
266    ) {
267        let svc_scope = Scope::Service {
268            namespace: ns_id.to_string(),
269            service: svc.id.clone(),
270        };
271
272        // Service limits (if defined)
273        if let Some(ref limits) = svc.limits {
274            flat.scope_limits.insert(svc_scope.clone(), limits.clone());
275        }
276
277        // Service upstreams
278        for (id, upstream) in &svc.upstreams {
279            flat.upstreams.insert(
280                QualifiedId::in_service(ns_id, &svc.id, id),
281                upstream.clone(),
282            );
283        }
284
285        // Service routes
286        for route in &svc.routes {
287            flat.routes.push((
288                QualifiedId::in_service(ns_id, &svc.id, &route.id),
289                route.clone(),
290            ));
291        }
292
293        // Service agents
294        for agent in &svc.agents {
295            flat.agents.insert(
296                QualifiedId::in_service(ns_id, &svc.id, &agent.id),
297                agent.clone(),
298            );
299        }
300
301        // Service filters
302        for (id, filter) in &svc.filters {
303            flat.filters.insert(
304                QualifiedId::in_service(ns_id, &svc.id, id),
305                filter.clone(),
306            );
307        }
308
309        // Service listener (singular)
310        if let Some(ref listener) = svc.listener {
311            flat.listeners.push((
312                QualifiedId::in_service(ns_id, &svc.id, &listener.id),
313                listener.clone(),
314            ));
315        }
316    }
317}
318
319// ============================================================================
320// Tests
321// ============================================================================
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::{
327        namespace::{ExportConfig, NamespaceConfig, ServiceConfig},
328        ConnectionPoolConfig, HttpVersionConfig, UpstreamTarget, UpstreamTimeouts,
329    };
330    use sentinel_common::types::LoadBalancingAlgorithm;
331
332    fn test_upstream(id: &str) -> UpstreamConfig {
333        UpstreamConfig {
334            id: id.to_string(),
335            targets: vec![UpstreamTarget {
336                address: "127.0.0.1:8080".to_string(),
337                weight: 1,
338                max_requests: None,
339                metadata: HashMap::new(),
340            }],
341            load_balancing: LoadBalancingAlgorithm::RoundRobin,
342            health_check: None,
343            connection_pool: ConnectionPoolConfig::default(),
344            timeouts: UpstreamTimeouts::default(),
345            tls: None,
346            http_version: HttpVersionConfig::default(),
347        }
348    }
349
350    fn test_config() -> Config {
351        let mut config = Config::default_for_testing();
352
353        // Add global upstream
354        config.upstreams.insert("global-backend".to_string(), test_upstream("global-backend"));
355
356        // Add namespace with upstream
357        let mut ns = NamespaceConfig::new("api");
358        ns.upstreams.insert("ns-backend".to_string(), test_upstream("ns-backend"));
359        ns.upstreams.insert("shared-backend".to_string(), test_upstream("shared-backend"));
360        ns.exports = ExportConfig {
361            upstreams: vec!["shared-backend".to_string()],
362            agents: vec![],
363            filters: vec![],
364        };
365
366        // Add service with upstream
367        let mut svc = ServiceConfig::new("payments");
368        svc.upstreams.insert("svc-backend".to_string(), test_upstream("svc-backend"));
369        ns.services.push(svc);
370
371        config.namespaces.push(ns);
372        config
373    }
374
375    #[test]
376    fn test_flatten_global_upstreams() {
377        let config = test_config();
378        let flat = config.flatten();
379
380        // Should have global upstream
381        let qid = QualifiedId::global("global-backend");
382        assert!(flat.upstreams.contains_key(&qid));
383        assert_eq!(flat.get_upstream(&qid).unwrap().id, "global-backend");
384    }
385
386    #[test]
387    fn test_flatten_namespace_upstreams() {
388        let config = test_config();
389        let flat = config.flatten();
390
391        // Should have namespace upstream
392        let qid = QualifiedId::namespaced("api", "ns-backend");
393        assert!(flat.upstreams.contains_key(&qid));
394        assert_eq!(flat.get_upstream(&qid).unwrap().id, "ns-backend");
395    }
396
397    #[test]
398    fn test_flatten_service_upstreams() {
399        let config = test_config();
400        let flat = config.flatten();
401
402        // Should have service upstream
403        let qid = QualifiedId::in_service("api", "payments", "svc-backend");
404        assert!(flat.upstreams.contains_key(&qid));
405        assert_eq!(flat.get_upstream(&qid).unwrap().id, "svc-backend");
406    }
407
408    #[test]
409    fn test_flatten_exported_upstreams() {
410        let config = test_config();
411        let flat = config.flatten();
412
413        // Should track exported upstreams
414        assert!(flat.is_upstream_exported("shared-backend"));
415        assert!(!flat.is_upstream_exported("ns-backend"));
416
417        let exported_qid = flat.get_exported_upstream_qid("shared-backend").unwrap();
418        assert_eq!(exported_qid.canonical(), "api:shared-backend");
419    }
420
421    #[test]
422    fn test_get_upstream_by_canonical() {
423        let config = test_config();
424        let flat = config.flatten();
425
426        // Should lookup by canonical string
427        let upstream = flat.get_upstream_by_canonical("api:ns-backend").unwrap();
428        assert_eq!(upstream.id, "ns-backend");
429
430        let service_upstream = flat.get_upstream_by_canonical("api:payments:svc-backend").unwrap();
431        assert_eq!(service_upstream.id, "svc-backend");
432    }
433
434    #[test]
435    fn test_flatten_scope_limits() {
436        let mut config = test_config();
437
438        // Add namespace limits
439        let ns = config.namespaces.get_mut(0).unwrap();
440        ns.limits = Some(Limits::for_testing());
441
442        let flat = config.flatten();
443
444        // Should have global limits
445        assert!(flat.scope_limits.contains_key(&Scope::Global));
446
447        // Should have namespace limits
448        assert!(flat.scope_limits.contains_key(&Scope::Namespace("api".to_string())));
449    }
450
451    #[test]
452    fn test_get_effective_limits() {
453        let mut config = test_config();
454
455        // Add namespace limits
456        let ns = config.namespaces.get_mut(0).unwrap();
457        ns.limits = Some(Limits::for_testing());
458
459        let flat = config.flatten();
460
461        // Service scope should fall back to namespace limits
462        let svc_scope = Scope::Service {
463            namespace: "api".to_string(),
464            service: "payments".to_string(),
465        };
466        let limits = flat.get_effective_limits(&svc_scope);
467        assert!(limits.is_some());
468    }
469
470    #[test]
471    fn test_routes_in_scope() {
472        let config = test_config();
473        let flat = config.flatten();
474
475        // Should have global routes (from default_for_testing)
476        let global_routes: Vec<_> = flat.routes_in_scope(&Scope::Global).collect();
477        assert!(!global_routes.is_empty());
478    }
479
480    #[test]
481    fn test_flatten_preserves_route_order() {
482        let config = test_config();
483        let flat = config.flatten();
484
485        // Routes should maintain order within their scope
486        let route_ids: Vec<_> = flat.routes.iter().map(|(qid, _)| qid.canonical()).collect();
487        assert!(!route_ids.is_empty());
488    }
489}