Skip to main content

zentinel_config/
lib.rs

1//! Configuration module for Zentinel proxy
2//!
3//! This module provides configuration parsing, validation, and hot-reload support
4//! with a focus on safety, security-first defaults, and operational clarity.
5//!
6//! # Module Organization
7//!
8//! - [`server`]: Server and listener configuration
9//! - [`routes`]: Route configuration and match conditions
10//! - [`upstreams`]: Upstream backend configuration
11//! - [`agents`]: External processing agent configuration
12//! - [`waf`]: WAF (Web Application Firewall) configuration
13//! - [`observability`]: Metrics, logging, and tracing configuration
14//! - [`filters`]: Filter types for request/response processing
15//! - [`validation`]: Configuration validation functions
16//! - `kdl`: KDL format parsing
17//! - `defaults`: Default embedded configuration
18//! - [`multi_file`]: Multi-file configuration loading
19
20use anyhow::{Context, Result};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23#[cfg(feature = "runtime")]
24use std::collections::HashSet;
25use std::path::Path;
26#[cfg(feature = "runtime")]
27use std::path::PathBuf;
28use tracing::{debug, info, trace, warn};
29use validator::Validate;
30
31use zentinel_common::{
32    errors::{ZentinelError, ZentinelResult},
33    limits::Limits,
34    types::Priority,
35};
36
37// ============================================================================
38// Module Declarations
39// ============================================================================
40
41pub mod agents;
42mod defaults;
43pub mod filters;
44pub mod flatten;
45mod kdl;
46#[cfg(feature = "runtime")]
47pub mod multi_file;
48pub mod namespace;
49pub mod observability;
50pub mod resolution;
51pub mod routes;
52pub mod server;
53pub mod upstreams;
54#[cfg(feature = "validation")]
55pub mod validate;
56pub mod validation;
57pub mod waf;
58
59// ============================================================================
60// Re-exports
61// ============================================================================
62
63// Agents
64pub use agents::{
65    AgentConfig, AgentEvent, AgentPoolConfig, AgentTlsConfig, AgentTransport, AgentType,
66    BodyStreamingMode, LoadBalanceStrategy,
67};
68
69// Defaults
70pub use defaults::{create_default_config, DEFAULT_CONFIG_KDL};
71
72// Filters
73pub use filters::*;
74// Explicit re-exports for gateway controller
75pub use filters::{Filter, FilterConfig, PathModifier, RedirectFilter, UrlRewriteFilter};
76
77// Multi-file (runtime only - uses glob which requires std::fs)
78#[cfg(feature = "runtime")]
79pub use multi_file::{ConfigDirectory, MultiFileLoader};
80
81// Namespace
82pub use namespace::{ExportConfig, NamespaceConfig, ServiceConfig};
83
84// Flatten
85pub use flatten::FlattenedConfig;
86
87// Resolution
88pub use resolution::ResourceResolver;
89
90// Observability
91pub use observability::{
92    AccessLogConfig, AccessLogFields, AuditLogConfig, ErrorLogConfig, LoggingConfig, MetricsConfig,
93    ObservabilityConfig, TracingBackend, TracingConfig,
94};
95
96// Routes
97pub use routes::{
98    ApiSchemaConfig, BuiltinHandler, CacheBackend, CacheStorageConfig, ErrorFormat, ErrorPage,
99    ErrorPageConfig, FailureMode, FallbackConfig, FallbackTriggers, FallbackUpstream,
100    GuardrailAction, GuardrailFailureMode, GuardrailsConfig, HeaderModifications, InferenceConfig,
101    InferenceProvider, InferenceRouting, InferenceRoutingStrategy, MatchCondition,
102    ModelRoutingConfig, ModelUpstreamMapping, PiiAction, PiiDetectionConfig, PromptInjectionConfig,
103    RateLimitPolicy, RouteCacheConfig, RouteConfig, RoutePolicies, ServiceType, StaticFileConfig,
104    TokenEstimation, TokenRateLimit,
105};
106
107// Server
108pub use server::{ListenerConfig, ListenerProtocol, ServerConfig, SniCertificate, TlsConfig};
109
110// Re-export TraceIdFormat from common for convenience
111pub use zentinel_common::TraceIdFormat;
112
113// Re-export budget types from common for convenience
114pub use zentinel_common::budget::{
115    BudgetPeriod, CostAttributionConfig, ModelPricing, TokenBudgetConfig,
116};
117
118// Upstreams
119pub use upstreams::{
120    ConnectionPoolConfig, HealthCheck, HttpVersionConfig, UpstreamConfig, UpstreamPeer,
121    UpstreamTarget, UpstreamTimeouts, UpstreamTlsConfig,
122};
123
124// Validation
125pub use validation::ValidationContext;
126
127// WAF
128pub use waf::{
129    BodyInspectionPolicy, ExclusionScope, RuleExclusion, WafConfig, WafEngine, WafMode, WafRuleset,
130};
131
132// Common types re-exported for convenience
133pub use zentinel_common::types::LoadBalancingAlgorithm;
134
135// ============================================================================
136// Main Configuration Structure
137// ============================================================================
138
139/// Current schema version supported by this build
140pub const CURRENT_SCHEMA_VERSION: &str = "1.0";
141
142/// Minimum schema version supported by this build
143pub const MIN_SCHEMA_VERSION: &str = "1.0";
144
145/// Main configuration structure for Zentinel proxy
146#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
147#[validate(schema(function = "validation::validate_config_semantics"))]
148pub struct Config {
149    /// Configuration schema version for compatibility checking
150    /// If not specified, defaults to current version
151    #[serde(default = "default_schema_version")]
152    pub schema_version: String,
153
154    /// Global server configuration
155    pub server: ServerConfig,
156
157    /// Listener configurations
158    #[validate(length(min = 1, message = "At least one listener is required"))]
159    pub listeners: Vec<ListenerConfig>,
160
161    /// Route configurations
162    pub routes: Vec<RouteConfig>,
163
164    /// Upstream pool configurations (can be empty if all routes are static)
165    #[serde(default)]
166    pub upstreams: HashMap<String, UpstreamConfig>,
167
168    /// Named filter configurations (referenced by routes)
169    #[serde(default)]
170    pub filters: HashMap<String, FilterConfig>,
171
172    /// Agent configurations
173    #[serde(default)]
174    pub agents: Vec<AgentConfig>,
175
176    /// WAF configuration
177    #[serde(default)]
178    pub waf: Option<WafConfig>,
179
180    /// Namespace configurations for hierarchical organization.
181    ///
182    /// Namespaces provide domain-driven boundaries within the configuration,
183    /// grouping related resources (routes, upstreams, agents, etc.) together.
184    /// Resources within namespaces can reference each other without qualification,
185    /// and can optionally be exported for global visibility.
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub namespaces: Vec<NamespaceConfig>,
188
189    /// Global limits configuration
190    #[serde(default)]
191    pub limits: Limits,
192
193    /// Observability configuration
194    #[serde(default)]
195    pub observability: ObservabilityConfig,
196
197    /// Global rate limit configuration
198    #[serde(default)]
199    pub rate_limits: GlobalRateLimitConfig,
200
201    /// Global cache storage configuration
202    #[serde(default)]
203    pub cache: Option<CacheStorageConfig>,
204
205    /// Default upstream for Phase 0 testing
206    #[serde(skip)]
207    pub default_upstream: Option<UpstreamPeer>,
208}
209
210/// Default schema version (current version)
211fn default_schema_version() -> String {
212    CURRENT_SCHEMA_VERSION.to_string()
213}
214
215/// Schema version compatibility result
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub enum SchemaCompatibility {
218    /// Version matches exactly
219    Exact,
220    /// Version is compatible (within supported range)
221    Compatible,
222    /// Version is newer than supported - may have unsupported features
223    Newer {
224        config_version: String,
225        max_supported: String,
226    },
227    /// Version is older than minimum supported
228    Older {
229        config_version: String,
230        min_supported: String,
231    },
232    /// Version format is invalid
233    Invalid {
234        config_version: String,
235        reason: String,
236    },
237}
238
239impl SchemaCompatibility {
240    /// Returns true if the config can be loaded (Exact, Compatible, or Newer with warning)
241    pub fn is_loadable(&self) -> bool {
242        matches!(self, Self::Exact | Self::Compatible | Self::Newer { .. })
243    }
244
245    /// Returns a warning message if applicable
246    pub fn warning(&self) -> Option<String> {
247        match self {
248            Self::Newer { config_version, max_supported } => Some(format!(
249                "Config schema version {} is newer than supported version {}. Some features may not work.",
250                config_version, max_supported
251            )),
252            _ => None,
253        }
254    }
255
256    /// Returns an error message if not loadable
257    pub fn error(&self) -> Option<String> {
258        match self {
259            Self::Older { config_version, min_supported } => Some(format!(
260                "Config schema version {} is older than minimum supported version {}. Please update your configuration.",
261                config_version, min_supported
262            )),
263            Self::Invalid { config_version, reason } => Some(format!(
264                "Invalid schema version '{}': {}",
265                config_version, reason
266            )),
267            _ => None,
268        }
269    }
270}
271
272/// Parse a version string into (major, minor) tuple
273fn parse_version(version: &str) -> Option<(u32, u32)> {
274    let parts: Vec<&str> = version.trim().split('.').collect();
275    if parts.len() != 2 {
276        return None;
277    }
278    let major = parts[0].parse().ok()?;
279    let minor = parts[1].parse().ok()?;
280    Some((major, minor))
281}
282
283/// Check schema version compatibility
284pub fn check_schema_compatibility(config_version: &str) -> SchemaCompatibility {
285    let config_ver = match parse_version(config_version) {
286        Some(v) => v,
287        None => {
288            return SchemaCompatibility::Invalid {
289                config_version: config_version.to_string(),
290                reason: "Expected format: major.minor (e.g., '1.0')".to_string(),
291            }
292        }
293    };
294
295    let current_ver = parse_version(CURRENT_SCHEMA_VERSION).unwrap();
296    let min_ver = parse_version(MIN_SCHEMA_VERSION).unwrap();
297
298    // Check if older than minimum
299    if config_ver < min_ver {
300        return SchemaCompatibility::Older {
301            config_version: config_version.to_string(),
302            min_supported: MIN_SCHEMA_VERSION.to_string(),
303        };
304    }
305
306    // Check if newer than current
307    if config_ver > current_ver {
308        return SchemaCompatibility::Newer {
309            config_version: config_version.to_string(),
310            max_supported: CURRENT_SCHEMA_VERSION.to_string(),
311        };
312    }
313
314    // Check if exact match
315    if config_ver == current_ver {
316        return SchemaCompatibility::Exact;
317    }
318
319    // Within range
320    SchemaCompatibility::Compatible
321}
322
323// ============================================================================
324// Config Implementation
325// ============================================================================
326
327impl Config {
328    /// Load configuration from a file
329    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
330        let path = path.as_ref();
331
332        trace!(
333            path = %path.display(),
334            "Loading configuration from file"
335        );
336
337        let content = std::fs::read_to_string(path)
338            .with_context(|| format!("Failed to read config file: {:?}", path))?;
339
340        let extension = path
341            .extension()
342            .and_then(|ext| ext.to_str())
343            .unwrap_or("kdl");
344
345        debug!(
346            path = %path.display(),
347            format = extension,
348            content_length = content.len(),
349            "Read configuration file"
350        );
351
352        let config = match extension {
353            "kdl" => {
354                let expanded = Self::expand_kdl_includes(&content, path)?;
355                Self::from_kdl(&expanded)
356            }
357            "json" => Self::from_json(&content),
358            "toml" => Self::from_toml(&content),
359            _ => Err(anyhow::anyhow!("Unsupported config format: {}", extension)),
360        }?;
361
362        info!(
363            path = %path.display(),
364            routes = config.routes.len(),
365            upstreams = config.upstreams.len(),
366            agents = config.agents.len(),
367            listeners = config.listeners.len(),
368            "Configuration loaded successfully"
369        );
370
371        Ok(config)
372    }
373
374    /// Expand `include` directives in KDL content.
375    ///
376    /// This pre-processes the KDL string, resolving any `include` directives
377    /// by reading the referenced files (supporting glob patterns) and
378    /// recursively expanding their includes. Returns a single KDL string
379    /// with all includes inlined.
380    #[cfg(feature = "runtime")]
381    fn expand_kdl_includes(content: &str, source_path: &Path) -> Result<String> {
382        // Quick check: if there are no include directives, return as-is
383        let doc: ::kdl::KdlDocument = content
384            .parse()
385            .map_err(|e| anyhow::anyhow!("KDL parse error during include expansion: {}", e))?;
386
387        let has_includes = doc.nodes().iter().any(|n| n.name().value() == "include");
388        if !has_includes {
389            return Ok(content.to_string());
390        }
391
392        let mut visited = HashSet::new();
393        Self::expand_includes_recursive(content, source_path, &mut visited)
394    }
395
396    /// Fallback when the `runtime` feature is not enabled.
397    #[cfg(not(feature = "runtime"))]
398    fn expand_kdl_includes(content: &str, source_path: &Path) -> Result<String> {
399        // Check if the content has include directives
400        let doc: ::kdl::KdlDocument = content
401            .parse()
402            .map_err(|e| anyhow::anyhow!("KDL parse error during include expansion: {}", e))?;
403
404        let has_includes = doc.nodes().iter().any(|n| n.name().value() == "include");
405        if has_includes {
406            return Err(anyhow::anyhow!(
407                "The 'include' directive in '{}' requires the 'runtime' feature.\n\
408                 Build with: cargo build --features runtime",
409                source_path.display()
410            ));
411        }
412
413        Ok(content.to_string())
414    }
415
416    /// Recursively expand include directives in a KDL file.
417    ///
418    /// Parses the KDL content, replaces `include` nodes with the contents of
419    /// the referenced files, and returns the merged KDL string. Uses a visited
420    /// set of canonical paths to detect and prevent circular includes.
421    #[cfg(feature = "runtime")]
422    fn expand_includes_recursive(
423        content: &str,
424        source_path: &Path,
425        visited: &mut HashSet<PathBuf>,
426    ) -> Result<String> {
427        let canonical = source_path
428            .canonicalize()
429            .with_context(|| format!("Failed to resolve config path: {}", source_path.display()))?;
430
431        if !visited.insert(canonical.clone()) {
432            return Err(anyhow::anyhow!(
433                "Circular include detected: '{}' has already been included",
434                source_path.display()
435            ));
436        }
437
438        let base_dir = canonical.parent().ok_or_else(|| {
439            anyhow::anyhow!(
440                "Config file has no parent directory: {}",
441                source_path.display()
442            )
443        })?;
444
445        let doc: ::kdl::KdlDocument = content.parse().map_err(|e| {
446            anyhow::anyhow!("KDL parse error in '{}': {}", source_path.display(), e)
447        })?;
448
449        let mut output = String::new();
450
451        for node in doc.nodes() {
452            if node.name().value() == "include" {
453                let pattern = node
454                    .entries()
455                    .iter()
456                    .find_map(|e| {
457                        if e.name().is_none() {
458                            e.value().as_string().map(|s| s.to_string())
459                        } else {
460                            None
461                        }
462                    })
463                    .ok_or_else(|| {
464                        anyhow::anyhow!(
465                            "The 'include' directive requires a string argument, e.g.: include \"routes/*.kdl\"\n\
466                             Found in: {}",
467                            source_path.display()
468                        )
469                    })?;
470
471                // Resolve pattern relative to source file's directory
472                let full_pattern = base_dir.join(&pattern);
473                let pattern_str = full_pattern.to_str().ok_or_else(|| {
474                    anyhow::anyhow!("Include pattern contains invalid UTF-8: {:?}", full_pattern)
475                })?;
476
477                let mut matched_any = false;
478                let mut paths: Vec<PathBuf> = Vec::new();
479
480                for entry in glob::glob(pattern_str).with_context(|| {
481                    format!(
482                        "Invalid glob pattern '{}' in {}",
483                        pattern,
484                        source_path.display()
485                    )
486                })? {
487                    let path = entry.with_context(|| {
488                        format!(
489                            "Error reading glob match for '{}' in {}",
490                            pattern,
491                            source_path.display()
492                        )
493                    })?;
494                    paths.push(path);
495                }
496
497                // Sort for deterministic include order
498                paths.sort();
499
500                for path in paths {
501                    matched_any = true;
502                    debug!(
503                        include = %path.display(),
504                        from = %source_path.display(),
505                        "Including config file"
506                    );
507
508                    let included_content = std::fs::read_to_string(&path).with_context(|| {
509                        format!(
510                            "Failed to read included config file '{}' (included from '{}')",
511                            path.display(),
512                            source_path.display()
513                        )
514                    })?;
515
516                    let expanded =
517                        Self::expand_includes_recursive(&included_content, &path, visited)?;
518                    output.push_str(&expanded);
519                    output.push('\n');
520                }
521
522                if !matched_any {
523                    warn!(
524                        pattern = %pattern,
525                        source = %source_path.display(),
526                        "Include pattern matched no files"
527                    );
528                }
529            } else {
530                // Serialize the node back to KDL
531                output.push_str(&node.to_string());
532                output.push('\n');
533            }
534        }
535
536        Ok(output)
537    }
538
539    /// Load the default embedded configuration.
540    ///
541    /// This is used when no configuration file is provided. It parses the
542    /// embedded KDL configuration, falling back to the programmatic default
543    /// if KDL parsing fails for any reason.
544    pub fn default_embedded() -> Result<Self> {
545        trace!("Loading embedded default configuration");
546
547        Self::from_kdl(DEFAULT_CONFIG_KDL).or_else(|e| {
548            warn!(
549                error = %e,
550                "Failed to parse embedded KDL config, using programmatic default"
551            );
552            Ok(create_default_config())
553        })
554    }
555
556    /// Parse configuration from KDL format
557    pub fn from_kdl(content: &str) -> Result<Self> {
558        trace!(content_length = content.len(), "Parsing KDL configuration");
559        let doc: ::kdl::KdlDocument = content.parse().map_err(|e: ::kdl::KdlError| {
560            use miette::Diagnostic;
561
562            let mut error_msg = String::new();
563            error_msg.push_str("KDL configuration parse error:\n\n");
564
565            let mut found_details = false;
566            if let Some(related) = e.related() {
567                for diagnostic in related {
568                    let diag_str = format!("{}", diagnostic);
569                    error_msg.push_str(&format!("  {}\n", diag_str));
570                    found_details = true;
571
572                    if let Some(labels) = diagnostic.labels() {
573                        for label in labels {
574                            let offset = label.offset();
575                            let (line, col) = kdl::offset_to_line_col(content, offset);
576                            error_msg
577                                .push_str(&format!("\n  --> at line {}, column {}\n", line, col));
578
579                            let lines: Vec<&str> = content.lines().collect();
580
581                            if line > 1 {
582                                if let Some(lc) = lines.get(line.saturating_sub(2)) {
583                                    error_msg.push_str(&format!("{:>4} | {}\n", line - 1, lc));
584                                }
585                            }
586
587                            if let Some(line_content) = lines.get(line.saturating_sub(1)) {
588                                error_msg.push_str(&format!("{:>4} | {}\n", line, line_content));
589                                error_msg.push_str(&format!(
590                                    "     | {}^",
591                                    " ".repeat(col.saturating_sub(1))
592                                ));
593                                if let Some(label_msg) = label.label() {
594                                    error_msg.push_str(&format!(" {}", label_msg));
595                                }
596                                error_msg.push('\n');
597                            }
598
599                            if let Some(lc) = lines.get(line) {
600                                error_msg.push_str(&format!("{:>4} | {}\n", line + 1, lc));
601                            }
602                        }
603                    }
604
605                    if let Some(help) = diagnostic.help() {
606                        error_msg.push_str(&format!("\n  Help: {}\n", help));
607                    }
608                }
609            }
610
611            if !found_details {
612                error_msg.push_str(&format!("  {}\n", e));
613                error_msg.push_str("\n  Note: Check your KDL syntax. Common issues:\n");
614                error_msg.push_str("    - Unclosed strings (missing closing quote)\n");
615                error_msg.push_str("    - Unclosed blocks (missing closing brace)\n");
616                error_msg.push_str("    - Invalid node names or values\n");
617            }
618
619            if let Some(help) = e.help() {
620                error_msg.push_str(&format!("\n  Help: {}\n", help));
621            }
622
623            anyhow::anyhow!("{}", error_msg)
624        })?;
625
626        kdl::parse_kdl_document(doc)
627    }
628
629    /// Parse configuration from JSON format
630    pub fn from_json(content: &str) -> Result<Self> {
631        trace!(content_length = content.len(), "Parsing JSON configuration");
632        serde_json::from_str(content).context("Failed to parse JSON configuration")
633    }
634
635    /// Parse configuration from TOML format
636    pub fn from_toml(content: &str) -> Result<Self> {
637        trace!(content_length = content.len(), "Parsing TOML configuration");
638        toml::from_str(content).context("Failed to parse TOML configuration")
639    }
640
641    /// Check schema version compatibility
642    pub fn check_schema_version(&self) -> SchemaCompatibility {
643        check_schema_compatibility(&self.schema_version)
644    }
645
646    /// Validate the configuration
647    pub fn validate(&self) -> ZentinelResult<()> {
648        trace!(
649            routes = self.routes.len(),
650            upstreams = self.upstreams.len(),
651            agents = self.agents.len(),
652            schema_version = %self.schema_version,
653            "Starting configuration validation"
654        );
655
656        // Check schema version compatibility
657        let compat = self.check_schema_version();
658        if let Some(warning) = compat.warning() {
659            warn!("{}", warning);
660        }
661        if !compat.is_loadable() {
662            return Err(ZentinelError::Config {
663                message: compat
664                    .error()
665                    .unwrap_or_else(|| "Unknown schema version error".to_string()),
666                source: None,
667            });
668        }
669        trace!(
670            schema_version = %self.schema_version,
671            compatibility = ?compat,
672            "Schema version check passed"
673        );
674
675        Validate::validate(self).map_err(|e| ZentinelError::Config {
676            message: format!("Configuration validation failed: {}", e),
677            source: None,
678        })?;
679
680        trace!("Schema validation passed");
681
682        self.validate_routes()?;
683        trace!("Route validation passed");
684
685        self.validate_upstreams()?;
686        trace!("Upstream validation passed");
687
688        self.validate_agents()?;
689        trace!("Agent validation passed");
690
691        self.limits.validate()?;
692        trace!("Limits validation passed");
693
694        debug!(
695            routes = self.routes.len(),
696            upstreams = self.upstreams.len(),
697            agents = self.agents.len(),
698            "Configuration validation successful"
699        );
700
701        Ok(())
702    }
703
704    fn validate_routes(&self) -> ZentinelResult<()> {
705        for route in &self.routes {
706            if let Some(upstream) = &route.upstream {
707                if !self.upstreams.contains_key(upstream) {
708                    return Err(ZentinelError::Config {
709                        message: format!(
710                            "Route '{}' references non-existent upstream '{}'",
711                            route.id, upstream
712                        ),
713                        source: None,
714                    });
715                }
716            }
717
718            for filter_id in &route.filters {
719                if !self.filters.contains_key(filter_id) {
720                    return Err(ZentinelError::Config {
721                        message: format!(
722                            "Route '{}' references non-existent filter '{}'",
723                            route.id, filter_id
724                        ),
725                        source: None,
726                    });
727                }
728            }
729        }
730
731        for (filter_id, filter_config) in &self.filters {
732            if let Filter::Agent(agent_filter) = &filter_config.filter {
733                if !self.agents.iter().any(|a| a.id == agent_filter.agent) {
734                    return Err(ZentinelError::Config {
735                        message: format!(
736                            "Filter '{}' references non-existent agent '{}'",
737                            filter_id, agent_filter.agent
738                        ),
739                        source: None,
740                    });
741                }
742            }
743        }
744
745        Ok(())
746    }
747
748    fn validate_upstreams(&self) -> ZentinelResult<()> {
749        for (id, upstream) in &self.upstreams {
750            if upstream.targets.is_empty() {
751                return Err(ZentinelError::Config {
752                    message: format!("Upstream '{}' has no targets", id),
753                    source: None,
754                });
755            }
756        }
757        Ok(())
758    }
759
760    fn validate_agents(&self) -> ZentinelResult<()> {
761        for agent in &self.agents {
762            if agent.timeout_ms == 0 {
763                return Err(ZentinelError::Config {
764                    message: format!("Agent '{}' has invalid timeout", agent.id),
765                    source: None,
766                });
767            }
768
769            if let AgentTransport::UnixSocket { path } = &agent.transport {
770                if !path.exists() && !path.parent().is_some_and(|p| p.exists()) {
771                    return Err(ZentinelError::Config {
772                        message: format!(
773                            "Agent '{}' unix socket path parent directory doesn't exist: {:?}",
774                            agent.id, path
775                        ),
776                        source: None,
777                    });
778                }
779            }
780        }
781        Ok(())
782    }
783
784    /// Create a default configuration for testing
785    pub fn default_for_testing() -> Self {
786        use zentinel_common::types::LoadBalancingAlgorithm;
787
788        let mut upstreams = HashMap::new();
789        upstreams.insert(
790            "default".to_string(),
791            UpstreamConfig {
792                id: "default".to_string(),
793                targets: vec![UpstreamTarget {
794                    address: "127.0.0.1:8081".to_string(),
795                    weight: 1,
796                    max_requests: None,
797                    metadata: HashMap::new(),
798                }],
799                load_balancing: LoadBalancingAlgorithm::RoundRobin,
800                sticky_session: None,
801                health_check: None,
802                connection_pool: ConnectionPoolConfig::default(),
803                timeouts: UpstreamTimeouts::default(),
804                tls: None,
805                http_version: HttpVersionConfig::default(),
806            },
807        );
808
809        Self {
810            schema_version: CURRENT_SCHEMA_VERSION.to_string(),
811            server: ServerConfig {
812                worker_threads: 4,
813                max_connections: 1000,
814                graceful_shutdown_timeout_secs: 30,
815                daemon: false,
816                pid_file: None,
817                user: None,
818                group: None,
819                working_directory: None,
820                trace_id_format: Default::default(),
821                auto_reload: false,
822            },
823            listeners: vec![ListenerConfig {
824                id: "http".to_string(),
825                address: "0.0.0.0:8080".to_string(),
826                protocol: ListenerProtocol::Http,
827                tls: None,
828                default_route: Some("default".to_string()),
829                request_timeout_secs: 60,
830                keepalive_timeout_secs: 75,
831                max_concurrent_streams: 100,
832                keepalive_max_requests: None,
833            }],
834            routes: vec![RouteConfig {
835                id: "default".to_string(),
836                priority: Priority::NORMAL,
837                matches: vec![MatchCondition::PathPrefix("/".to_string())],
838                upstream: Some("default".to_string()),
839                service_type: ServiceType::Web,
840                policies: RoutePolicies::default(),
841                filters: vec![],
842                builtin_handler: None,
843                waf_enabled: false,
844                circuit_breaker: None,
845                retry_policy: None,
846                static_files: None,
847                api_schema: None,
848                inference: None,
849                error_pages: None,
850                websocket: false,
851                websocket_inspection: false,
852                shadow: None,
853                fallback: None,
854            }],
855            upstreams,
856            filters: HashMap::new(),
857            agents: vec![],
858            waf: None,
859            namespaces: vec![],
860            limits: Limits::for_testing(),
861            observability: ObservabilityConfig::default(),
862            rate_limits: GlobalRateLimitConfig::default(),
863            cache: None,
864            default_upstream: Some(UpstreamPeer {
865                address: "127.0.0.1:8081".to_string(),
866                tls: false,
867                host: "localhost".to_string(),
868                connect_timeout_secs: 10,
869                read_timeout_secs: 30,
870                write_timeout_secs: 30,
871            }),
872        }
873    }
874
875    /// Reload configuration from the same file path
876    pub fn reload(&mut self, path: impl AsRef<Path>) -> ZentinelResult<()> {
877        let path = path.as_ref();
878        debug!(
879            path = %path.display(),
880            "Reloading configuration"
881        );
882
883        let new_config = Self::from_file(path).map_err(|e| ZentinelError::Config {
884            message: format!("Failed to reload configuration: {}", e),
885            source: None,
886        })?;
887
888        new_config.validate()?;
889
890        info!(
891            path = %path.display(),
892            routes = new_config.routes.len(),
893            upstreams = new_config.upstreams.len(),
894            "Configuration reloaded successfully"
895        );
896
897        *self = new_config;
898        Ok(())
899    }
900
901    /// Get a route by ID
902    pub fn get_route(&self, id: &str) -> Option<&RouteConfig> {
903        self.routes.iter().find(|r| r.id == id)
904    }
905
906    /// Get an upstream by ID
907    pub fn get_upstream(&self, id: &str) -> Option<&UpstreamConfig> {
908        self.upstreams.get(id)
909    }
910
911    /// Get an agent by ID
912    pub fn get_agent(&self, id: &str) -> Option<&AgentConfig> {
913        self.agents.iter().find(|a| a.id == id)
914    }
915}
916
917// ============================================================================
918// Tests
919// ============================================================================
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    #[test]
926    fn test_parse_version() {
927        assert_eq!(parse_version("1.0"), Some((1, 0)));
928        assert_eq!(parse_version("2.5"), Some((2, 5)));
929        assert_eq!(parse_version("10.20"), Some((10, 20)));
930        assert_eq!(parse_version("1"), None);
931        assert_eq!(parse_version("1.0.0"), None);
932        assert_eq!(parse_version("abc"), None);
933        assert_eq!(parse_version(""), None);
934    }
935
936    #[test]
937    fn test_schema_compatibility_exact() {
938        let compat = check_schema_compatibility(CURRENT_SCHEMA_VERSION);
939        assert_eq!(compat, SchemaCompatibility::Exact);
940        assert!(compat.is_loadable());
941        assert!(compat.warning().is_none());
942        assert!(compat.error().is_none());
943    }
944
945    #[test]
946    fn test_schema_compatibility_newer() {
947        let compat = check_schema_compatibility("99.0");
948        assert!(matches!(compat, SchemaCompatibility::Newer { .. }));
949        assert!(compat.is_loadable()); // Newer versions are loadable with warning
950        assert!(compat.warning().is_some());
951        assert!(compat.error().is_none());
952    }
953
954    #[test]
955    fn test_schema_compatibility_older() {
956        // This test assumes MIN_SCHEMA_VERSION is "1.0"
957        let compat = check_schema_compatibility("0.5");
958        assert!(matches!(compat, SchemaCompatibility::Older { .. }));
959        assert!(!compat.is_loadable());
960        assert!(compat.warning().is_none());
961        assert!(compat.error().is_some());
962    }
963
964    #[test]
965    fn test_schema_compatibility_invalid() {
966        let compat = check_schema_compatibility("not-a-version");
967        assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
968        assert!(!compat.is_loadable());
969        assert!(compat.error().is_some());
970
971        let compat = check_schema_compatibility("1.0.0");
972        assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
973    }
974
975    #[test]
976    fn test_default_schema_version() {
977        let config = Config::default_for_testing();
978        assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
979    }
980
981    #[test]
982    fn test_kdl_with_schema_version() {
983        let kdl = r#"
984            schema-version "1.0"
985            server {
986                worker-threads 4
987            }
988            listeners {
989                listener "http" {
990                    address "0.0.0.0:8080"
991                    protocol "http"
992                }
993            }
994        "#;
995        let config = Config::from_kdl(kdl).unwrap();
996        assert_eq!(config.schema_version, "1.0");
997    }
998
999    #[test]
1000    fn test_kdl_without_schema_version_uses_default() {
1001        let kdl = r#"
1002            server {
1003                worker-threads 4
1004            }
1005            listeners {
1006                listener "http" {
1007                    address "0.0.0.0:8080"
1008                    protocol "http"
1009                }
1010            }
1011        "#;
1012        let config = Config::from_kdl(kdl).unwrap();
1013        assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
1014    }
1015
1016    #[test]
1017    fn test_from_kdl_rejects_include_directive() {
1018        let kdl = r#"
1019            include "routes/*.kdl"
1020            system {
1021                worker-threads 4
1022            }
1023            listeners {
1024                listener "http" {
1025                    address "0.0.0.0:8080"
1026                    protocol "http"
1027                }
1028            }
1029        "#;
1030        let err = Config::from_kdl(kdl).unwrap_err();
1031        assert!(
1032            err.to_string()
1033                .contains("not supported when parsing raw KDL strings"),
1034            "Expected helpful include error, got: {}",
1035            err
1036        );
1037    }
1038
1039    #[test]
1040    fn test_from_file_with_include() {
1041        let dir = tempfile::tempdir().unwrap();
1042
1043        // Write an included file with routes
1044        let routes_dir = dir.path().join("routes");
1045        std::fs::create_dir(&routes_dir).unwrap();
1046        std::fs::write(
1047            routes_dir.join("api.kdl"),
1048            r#"
1049routes {
1050    route "api" {
1051        match {
1052            path-prefix "/api"
1053        }
1054        upstream "backend"
1055    }
1056}
1057"#,
1058        )
1059        .unwrap();
1060
1061        // Write the main config that includes the routes file
1062        let main_config = dir.path().join("zentinel.kdl");
1063        std::fs::write(
1064            &main_config,
1065            r#"
1066schema-version "1.0"
1067system {
1068    worker-threads 4
1069}
1070listeners {
1071    listener "http" {
1072        address "0.0.0.0:8080"
1073        protocol "http"
1074    }
1075}
1076upstreams {
1077    upstream "backend" {
1078        target "127.0.0.1:9000"
1079    }
1080}
1081include "routes/*.kdl"
1082"#,
1083        )
1084        .unwrap();
1085
1086        let config = Config::from_file(&main_config).unwrap();
1087        assert_eq!(config.routes.len(), 1);
1088        assert_eq!(config.routes[0].id, "api");
1089    }
1090
1091    #[test]
1092    fn test_from_file_with_no_includes_still_works() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let config_path = dir.path().join("zentinel.kdl");
1095        std::fs::write(
1096            &config_path,
1097            r#"
1098schema-version "1.0"
1099system {
1100    worker-threads 4
1101}
1102listeners {
1103    listener "http" {
1104        address "0.0.0.0:8080"
1105        protocol "http"
1106    }
1107}
1108"#,
1109        )
1110        .unwrap();
1111
1112        let config = Config::from_file(&config_path).unwrap();
1113        assert_eq!(config.listeners.len(), 1);
1114    }
1115
1116    #[test]
1117    fn test_include_circular_detection() {
1118        let dir = tempfile::tempdir().unwrap();
1119
1120        let a_path = dir.path().join("a.kdl");
1121        let b_path = dir.path().join("b.kdl");
1122
1123        std::fs::write(&a_path, format!("include \"{}\"", b_path.display())).unwrap();
1124        std::fs::write(&b_path, format!("include \"{}\"", a_path.display())).unwrap();
1125
1126        let err = Config::from_file(&a_path).unwrap_err();
1127        assert!(
1128            err.to_string().contains("Circular include detected"),
1129            "Expected circular include error, got: {}",
1130            err
1131        );
1132    }
1133
1134    #[test]
1135    fn test_include_no_match_warns_but_succeeds() {
1136        let dir = tempfile::tempdir().unwrap();
1137        let config_path = dir.path().join("zentinel.kdl");
1138        std::fs::write(
1139            &config_path,
1140            r#"
1141system {
1142    worker-threads 4
1143}
1144listeners {
1145    listener "http" {
1146        address "0.0.0.0:8080"
1147        protocol "http"
1148    }
1149}
1150include "nonexistent/*.kdl"
1151"#,
1152        )
1153        .unwrap();
1154
1155        // Should succeed even with no matching files
1156        let config = Config::from_file(&config_path).unwrap();
1157        assert_eq!(config.listeners.len(), 1);
1158    }
1159}