Skip to main content

zentinel_config/
resolution.rs

1//! Resource resolution with scoped lookups.
2//!
3//! This module provides the [`ResourceResolver`] which handles looking up
4//! resources across the configuration hierarchy following the "most specific wins"
5//! resolution order: Service → Namespace → Exported → Global.
6//!
7//! # Resolution Rules
8//!
9//! When resolving an unqualified reference (e.g., `"backend"`):
10//! 1. Check the current scope (service-local if in a service)
11//! 2. Check the parent namespace scope
12//! 3. Check exported resources from other namespaces
13//! 4. Check global resources
14//!
15//! Qualified references (e.g., `"api:backend"`) bypass this chain and
16//! resolve directly to the specified scope.
17
18use zentinel_common::ids::{QualifiedId, Scope};
19
20use crate::{AgentConfig, Config, FilterConfig, NamespaceConfig, ServiceConfig, UpstreamConfig};
21
22// ============================================================================
23// Resource Resolver
24// ============================================================================
25
26/// Resolver for looking up resources across the configuration hierarchy.
27///
28/// The resolver implements the "most specific wins" resolution strategy,
29/// searching from the most specific scope (service) to the least specific
30/// (global) until a match is found.
31///
32/// # Example
33///
34/// ```ignore
35/// let resolver = ResourceResolver::new(&config);
36///
37/// // From within a service, "backend" resolves through the chain:
38/// // 1. api:payments:backend (service-local)
39/// // 2. api:backend (namespace)
40/// // 3. backend (exported or global)
41/// let scope = Scope::Service {
42///     namespace: "api".to_string(),
43///     service: "payments".to_string(),
44/// };
45/// let upstream = resolver.resolve_upstream("backend", &scope);
46/// ```
47pub struct ResourceResolver<'a> {
48    config: &'a Config,
49}
50
51impl<'a> ResourceResolver<'a> {
52    /// Create a new resolver for the given configuration.
53    pub fn new(config: &'a Config) -> Self {
54        Self { config }
55    }
56
57    /// Get the underlying configuration.
58    pub fn config(&self) -> &'a Config {
59        self.config
60    }
61
62    // ========================================================================
63    // Upstream Resolution
64    // ========================================================================
65
66    /// Resolve an upstream reference from the given scope.
67    ///
68    /// Resolution order for unqualified references:
69    /// 1. Service-local (if in a service scope)
70    /// 2. Namespace-local (if in a namespace or service scope)
71    /// 3. Exported from any namespace
72    /// 4. Global
73    ///
74    /// Qualified references (containing `:`) resolve directly.
75    pub fn resolve_upstream(
76        &self,
77        reference: &str,
78        from_scope: &Scope,
79    ) -> Option<&'a UpstreamConfig> {
80        // Qualified reference - direct lookup
81        if reference.contains(':') {
82            return self.resolve_upstream_qualified(&QualifiedId::parse(reference));
83        }
84
85        // Unqualified - search scope chain
86        match from_scope {
87            Scope::Service { namespace, service } => {
88                // 1. Service-local
89                if let Some(upstream) = self.find_service_upstream(namespace, service, reference) {
90                    return Some(upstream);
91                }
92                // 2. Namespace-local
93                if let Some(upstream) = self.find_namespace_upstream(namespace, reference) {
94                    return Some(upstream);
95                }
96                // 3. Exported
97                if let Some(upstream) = self.find_exported_upstream(reference) {
98                    return Some(upstream);
99                }
100                // 4. Global
101                self.config.upstreams.get(reference)
102            }
103            Scope::Namespace(namespace) => {
104                // 1. Namespace-local
105                if let Some(upstream) = self.find_namespace_upstream(namespace, reference) {
106                    return Some(upstream);
107                }
108                // 2. Exported
109                if let Some(upstream) = self.find_exported_upstream(reference) {
110                    return Some(upstream);
111                }
112                // 3. Global
113                self.config.upstreams.get(reference)
114            }
115            Scope::Global => {
116                // 1. Global
117                if let Some(upstream) = self.config.upstreams.get(reference) {
118                    return Some(upstream);
119                }
120                // 2. Exported (visible from anywhere including global)
121                self.find_exported_upstream(reference)
122            }
123        }
124    }
125
126    /// Resolve a qualified upstream ID directly.
127    fn resolve_upstream_qualified(&self, qid: &QualifiedId) -> Option<&'a UpstreamConfig> {
128        match &qid.scope {
129            Scope::Global => self.config.upstreams.get(&qid.name),
130            Scope::Namespace(ns) => self.find_namespace_upstream(ns, &qid.name),
131            Scope::Service { namespace, service } => {
132                self.find_service_upstream(namespace, service, &qid.name)
133            }
134        }
135    }
136
137    fn find_namespace_upstream(&self, ns_id: &str, name: &str) -> Option<&'a UpstreamConfig> {
138        self.config
139            .namespaces
140            .iter()
141            .find(|ns| ns.id == ns_id)
142            .and_then(|ns| ns.upstreams.get(name))
143    }
144
145    fn find_service_upstream(
146        &self,
147        ns_id: &str,
148        svc_id: &str,
149        name: &str,
150    ) -> Option<&'a UpstreamConfig> {
151        self.config
152            .namespaces
153            .iter()
154            .find(|ns| ns.id == ns_id)
155            .and_then(|ns| ns.services.iter().find(|s| s.id == svc_id))
156            .and_then(|svc| svc.upstreams.get(name))
157    }
158
159    fn find_exported_upstream(&self, name: &str) -> Option<&'a UpstreamConfig> {
160        for ns in &self.config.namespaces {
161            if ns.exports.upstreams.contains(&name.to_string()) {
162                if let Some(upstream) = ns.upstreams.get(name) {
163                    return Some(upstream);
164                }
165            }
166        }
167        None
168    }
169
170    // ========================================================================
171    // Agent Resolution
172    // ========================================================================
173
174    /// Resolve an agent reference from the given scope.
175    pub fn resolve_agent(&self, reference: &str, from_scope: &Scope) -> Option<&'a AgentConfig> {
176        // Qualified reference - direct lookup
177        if reference.contains(':') {
178            return self.resolve_agent_qualified(&QualifiedId::parse(reference));
179        }
180
181        // Unqualified - search scope chain
182        match from_scope {
183            Scope::Service { namespace, service } => {
184                // 1. Service-local
185                if let Some(agent) = self.find_service_agent(namespace, service, reference) {
186                    return Some(agent);
187                }
188                // 2. Namespace-local
189                if let Some(agent) = self.find_namespace_agent(namespace, reference) {
190                    return Some(agent);
191                }
192                // 3. Exported
193                if let Some(agent) = self.find_exported_agent(reference) {
194                    return Some(agent);
195                }
196                // 4. Global
197                self.config.agents.iter().find(|a| a.id == reference)
198            }
199            Scope::Namespace(namespace) => {
200                // 1. Namespace-local
201                if let Some(agent) = self.find_namespace_agent(namespace, reference) {
202                    return Some(agent);
203                }
204                // 2. Exported
205                if let Some(agent) = self.find_exported_agent(reference) {
206                    return Some(agent);
207                }
208                // 3. Global
209                self.config.agents.iter().find(|a| a.id == reference)
210            }
211            Scope::Global => {
212                // 1. Global
213                if let Some(agent) = self.config.agents.iter().find(|a| a.id == reference) {
214                    return Some(agent);
215                }
216                // 2. Exported (visible from anywhere including global)
217                self.find_exported_agent(reference)
218            }
219        }
220    }
221
222    fn resolve_agent_qualified(&self, qid: &QualifiedId) -> Option<&'a AgentConfig> {
223        match &qid.scope {
224            Scope::Global => self.config.agents.iter().find(|a| a.id == qid.name),
225            Scope::Namespace(ns) => self.find_namespace_agent(ns, &qid.name),
226            Scope::Service { namespace, service } => {
227                self.find_service_agent(namespace, service, &qid.name)
228            }
229        }
230    }
231
232    fn find_namespace_agent(&self, ns_id: &str, name: &str) -> Option<&'a AgentConfig> {
233        self.config
234            .namespaces
235            .iter()
236            .find(|ns| ns.id == ns_id)
237            .and_then(|ns| ns.agents.iter().find(|a| a.id == name))
238    }
239
240    fn find_service_agent(&self, ns_id: &str, svc_id: &str, name: &str) -> Option<&'a AgentConfig> {
241        self.config
242            .namespaces
243            .iter()
244            .find(|ns| ns.id == ns_id)
245            .and_then(|ns| ns.services.iter().find(|s| s.id == svc_id))
246            .and_then(|svc| svc.agents.iter().find(|a| a.id == name))
247    }
248
249    fn find_exported_agent(&self, name: &str) -> Option<&'a AgentConfig> {
250        for ns in &self.config.namespaces {
251            if ns.exports.agents.contains(&name.to_string()) {
252                if let Some(agent) = ns.agents.iter().find(|a| a.id == name) {
253                    return Some(agent);
254                }
255            }
256        }
257        None
258    }
259
260    // ========================================================================
261    // Filter Resolution
262    // ========================================================================
263
264    /// Resolve a filter reference from the given scope.
265    pub fn resolve_filter(&self, reference: &str, from_scope: &Scope) -> Option<&'a FilterConfig> {
266        // Qualified reference - direct lookup
267        if reference.contains(':') {
268            return self.resolve_filter_qualified(&QualifiedId::parse(reference));
269        }
270
271        // Unqualified - search scope chain
272        match from_scope {
273            Scope::Service { namespace, service } => {
274                // 1. Service-local
275                if let Some(filter) = self.find_service_filter(namespace, service, reference) {
276                    return Some(filter);
277                }
278                // 2. Namespace-local
279                if let Some(filter) = self.find_namespace_filter(namespace, reference) {
280                    return Some(filter);
281                }
282                // 3. Exported
283                if let Some(filter) = self.find_exported_filter(reference) {
284                    return Some(filter);
285                }
286                // 4. Global
287                self.config.filters.get(reference)
288            }
289            Scope::Namespace(namespace) => {
290                // 1. Namespace-local
291                if let Some(filter) = self.find_namespace_filter(namespace, reference) {
292                    return Some(filter);
293                }
294                // 2. Exported
295                if let Some(filter) = self.find_exported_filter(reference) {
296                    return Some(filter);
297                }
298                // 3. Global
299                self.config.filters.get(reference)
300            }
301            Scope::Global => {
302                // 1. Global
303                if let Some(filter) = self.config.filters.get(reference) {
304                    return Some(filter);
305                }
306                // 2. Exported (visible from anywhere including global)
307                self.find_exported_filter(reference)
308            }
309        }
310    }
311
312    fn resolve_filter_qualified(&self, qid: &QualifiedId) -> Option<&'a FilterConfig> {
313        match &qid.scope {
314            Scope::Global => self.config.filters.get(&qid.name),
315            Scope::Namespace(ns) => self.find_namespace_filter(ns, &qid.name),
316            Scope::Service { namespace, service } => {
317                self.find_service_filter(namespace, service, &qid.name)
318            }
319        }
320    }
321
322    fn find_namespace_filter(&self, ns_id: &str, name: &str) -> Option<&'a FilterConfig> {
323        self.config
324            .namespaces
325            .iter()
326            .find(|ns| ns.id == ns_id)
327            .and_then(|ns| ns.filters.get(name))
328    }
329
330    fn find_service_filter(
331        &self,
332        ns_id: &str,
333        svc_id: &str,
334        name: &str,
335    ) -> Option<&'a FilterConfig> {
336        self.config
337            .namespaces
338            .iter()
339            .find(|ns| ns.id == ns_id)
340            .and_then(|ns| ns.services.iter().find(|s| s.id == svc_id))
341            .and_then(|svc| svc.filters.get(name))
342    }
343
344    fn find_exported_filter(&self, name: &str) -> Option<&'a FilterConfig> {
345        for ns in &self.config.namespaces {
346            if ns.exports.filters.contains(&name.to_string()) {
347                if let Some(filter) = ns.filters.get(name) {
348                    return Some(filter);
349                }
350            }
351        }
352        None
353    }
354
355    // ========================================================================
356    // Namespace/Service Lookups
357    // ========================================================================
358
359    /// Get a namespace by ID.
360    pub fn get_namespace(&self, id: &str) -> Option<&'a NamespaceConfig> {
361        self.config.namespaces.iter().find(|ns| ns.id == id)
362    }
363
364    /// Get a service by namespace and service ID.
365    pub fn get_service(&self, namespace: &str, service: &str) -> Option<&'a ServiceConfig> {
366        self.get_namespace(namespace)
367            .and_then(|ns| ns.services.iter().find(|s| s.id == service))
368    }
369
370    /// Check if an upstream reference can be resolved from the given scope.
371    pub fn can_resolve_upstream(&self, reference: &str, from_scope: &Scope) -> bool {
372        self.resolve_upstream(reference, from_scope).is_some()
373    }
374
375    /// Check if an agent reference can be resolved from the given scope.
376    pub fn can_resolve_agent(&self, reference: &str, from_scope: &Scope) -> bool {
377        self.resolve_agent(reference, from_scope).is_some()
378    }
379
380    /// Check if a filter reference can be resolved from the given scope.
381    pub fn can_resolve_filter(&self, reference: &str, from_scope: &Scope) -> bool {
382        self.resolve_filter(reference, from_scope).is_some()
383    }
384}
385
386// ============================================================================
387// Tests
388// ============================================================================
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::{
394        namespace::{ExportConfig, NamespaceConfig, ServiceConfig},
395        ConnectionPoolConfig, HttpVersionConfig, UpstreamTarget, UpstreamTimeouts,
396    };
397    use std::collections::HashMap;
398    use zentinel_common::types::LoadBalancingAlgorithm;
399
400    fn test_upstream(id: &str) -> UpstreamConfig {
401        UpstreamConfig {
402            id: id.to_string(),
403            targets: vec![UpstreamTarget {
404                address: "127.0.0.1:8080".to_string(),
405                weight: 1,
406                max_requests: None,
407                metadata: HashMap::new(),
408            }],
409            load_balancing: LoadBalancingAlgorithm::RoundRobin,
410            sticky_session: None,
411            health_check: None,
412            connection_pool: ConnectionPoolConfig::default(),
413            timeouts: UpstreamTimeouts::default(),
414            tls: None,
415            http_version: HttpVersionConfig::default(),
416        }
417    }
418
419    fn test_config() -> Config {
420        let mut config = Config::default_for_testing();
421
422        // Add global upstream
423        config.upstreams.insert(
424            "global-backend".to_string(),
425            test_upstream("global-backend"),
426        );
427
428        // Add namespace with upstream
429        let mut ns = NamespaceConfig::new("api");
430        ns.upstreams
431            .insert("ns-backend".to_string(), test_upstream("ns-backend"));
432        ns.upstreams.insert(
433            "shared-backend".to_string(),
434            test_upstream("shared-backend"),
435        );
436        ns.exports = ExportConfig {
437            upstreams: vec!["shared-backend".to_string()],
438            agents: vec![],
439            filters: vec![],
440        };
441
442        // Add service with upstream
443        let mut svc = ServiceConfig::new("payments");
444        svc.upstreams
445            .insert("svc-backend".to_string(), test_upstream("svc-backend"));
446        ns.services.push(svc);
447
448        config.namespaces.push(ns);
449        config
450    }
451
452    #[test]
453    fn test_resolve_global_upstream_from_global() {
454        let config = test_config();
455        let resolver = ResourceResolver::new(&config);
456
457        let result = resolver.resolve_upstream("global-backend", &Scope::Global);
458        assert!(result.is_some());
459        assert_eq!(result.unwrap().id, "global-backend");
460    }
461
462    #[test]
463    fn test_resolve_global_upstream_from_namespace() {
464        let config = test_config();
465        let resolver = ResourceResolver::new(&config);
466
467        let scope = Scope::Namespace("api".to_string());
468        let result = resolver.resolve_upstream("global-backend", &scope);
469        assert!(result.is_some());
470        assert_eq!(result.unwrap().id, "global-backend");
471    }
472
473    #[test]
474    fn test_resolve_namespace_upstream_from_namespace() {
475        let config = test_config();
476        let resolver = ResourceResolver::new(&config);
477
478        let scope = Scope::Namespace("api".to_string());
479        let result = resolver.resolve_upstream("ns-backend", &scope);
480        assert!(result.is_some());
481        assert_eq!(result.unwrap().id, "ns-backend");
482    }
483
484    #[test]
485    fn test_namespace_upstream_not_visible_from_global() {
486        let config = test_config();
487        let resolver = ResourceResolver::new(&config);
488
489        let result = resolver.resolve_upstream("ns-backend", &Scope::Global);
490        assert!(result.is_none());
491    }
492
493    #[test]
494    fn test_resolve_service_upstream_from_service() {
495        let config = test_config();
496        let resolver = ResourceResolver::new(&config);
497
498        let scope = Scope::Service {
499            namespace: "api".to_string(),
500            service: "payments".to_string(),
501        };
502        let result = resolver.resolve_upstream("svc-backend", &scope);
503        assert!(result.is_some());
504        assert_eq!(result.unwrap().id, "svc-backend");
505    }
506
507    #[test]
508    fn test_service_can_access_namespace_upstream() {
509        let config = test_config();
510        let resolver = ResourceResolver::new(&config);
511
512        let scope = Scope::Service {
513            namespace: "api".to_string(),
514            service: "payments".to_string(),
515        };
516        let result = resolver.resolve_upstream("ns-backend", &scope);
517        assert!(result.is_some());
518        assert_eq!(result.unwrap().id, "ns-backend");
519    }
520
521    #[test]
522    fn test_service_can_access_global_upstream() {
523        let config = test_config();
524        let resolver = ResourceResolver::new(&config);
525
526        let scope = Scope::Service {
527            namespace: "api".to_string(),
528            service: "payments".to_string(),
529        };
530        let result = resolver.resolve_upstream("global-backend", &scope);
531        assert!(result.is_some());
532        assert_eq!(result.unwrap().id, "global-backend");
533    }
534
535    #[test]
536    fn test_exported_upstream_visible_globally() {
537        let config = test_config();
538        let resolver = ResourceResolver::new(&config);
539
540        // Exported upstream should be visible from global scope
541        let result = resolver.resolve_upstream("shared-backend", &Scope::Global);
542        assert!(result.is_some());
543        assert_eq!(result.unwrap().id, "shared-backend");
544    }
545
546    #[test]
547    fn test_exported_upstream_visible_from_other_namespace() {
548        let mut config = test_config();
549
550        // Add another namespace
551        let other_ns = NamespaceConfig::new("web");
552        config.namespaces.push(other_ns);
553
554        let resolver = ResourceResolver::new(&config);
555
556        // Should be able to access exported upstream from api namespace
557        let scope = Scope::Namespace("web".to_string());
558        let result = resolver.resolve_upstream("shared-backend", &scope);
559        assert!(result.is_some());
560        assert_eq!(result.unwrap().id, "shared-backend");
561    }
562
563    #[test]
564    fn test_qualified_reference_direct_lookup() {
565        let config = test_config();
566        let resolver = ResourceResolver::new(&config);
567
568        // Qualified reference bypasses scope chain
569        let result = resolver.resolve_upstream("api:ns-backend", &Scope::Global);
570        assert!(result.is_some());
571        assert_eq!(result.unwrap().id, "ns-backend");
572    }
573
574    #[test]
575    fn test_qualified_service_reference() {
576        let config = test_config();
577        let resolver = ResourceResolver::new(&config);
578
579        // Qualified service reference
580        let result = resolver.resolve_upstream("api:payments:svc-backend", &Scope::Global);
581        assert!(result.is_some());
582        assert_eq!(result.unwrap().id, "svc-backend");
583    }
584
585    #[test]
586    fn test_nonexistent_upstream() {
587        let config = test_config();
588        let resolver = ResourceResolver::new(&config);
589
590        let result = resolver.resolve_upstream("nonexistent", &Scope::Global);
591        assert!(result.is_none());
592    }
593
594    #[test]
595    fn test_can_resolve_upstream() {
596        let config = test_config();
597        let resolver = ResourceResolver::new(&config);
598
599        assert!(resolver.can_resolve_upstream("global-backend", &Scope::Global));
600        assert!(!resolver.can_resolve_upstream("ns-backend", &Scope::Global));
601        assert!(resolver.can_resolve_upstream("ns-backend", &Scope::Namespace("api".to_string())));
602    }
603}