sentinel_proxy/reload/
validators.rs

1//! Configuration validators for hot reload.
2//!
3//! These validators perform runtime-specific validation that complements
4//! the schema-level validation in sentinel-config.
5
6use sentinel_common::errors::{SentinelError, SentinelResult};
7use sentinel_config::Config;
8
9use super::ConfigValidator;
10
11/// Route configuration validator
12///
13/// Performs runtime-specific route validation that complements the schema-level
14/// validation in sentinel-config. This validator focuses on aspects that may
15/// change during hot reload.
16pub struct RouteValidator;
17
18#[async_trait::async_trait]
19impl ConfigValidator for RouteValidator {
20    async fn validate(&self, config: &Config) -> SentinelResult<()> {
21        // Most validation is now handled by Config's validate_config_semantics
22        // This validator handles runtime-specific checks
23
24        // Check for routes with both upstream and static-files (ambiguous config)
25        for route in &config.routes {
26            if route.upstream.is_some() && route.static_files.is_some() {
27                return Err(SentinelError::Config {
28                    message: format!(
29                        "Route '{}' has both 'upstream' and 'static-files' configured.\n\
30                         A route can only be one type. Choose either:\n\
31                         - Remove 'upstream' to serve static files\n\
32                         - Remove 'static-files' to proxy to upstream",
33                        route.id
34                    ),
35                    source: None,
36                });
37            }
38        }
39
40        // Check for static routes with non-existent root directories
41        for route in &config.routes {
42            if let Some(ref static_config) = route.static_files {
43                if !static_config.root.exists() {
44                    return Err(SentinelError::Config {
45                        message: format!(
46                            "Route '{}' static files root directory '{}' does not exist.\n\
47                             Hint: Create the directory or update the path:\n\
48                             \n\
49                             mkdir -p {}\n\
50                             \n\
51                             Or change the configuration:\n\
52                             static-files {{\n\
53                                 root \"/path/to/existing/directory\"\n\
54                             }}",
55                            route.id,
56                            static_config.root.display(),
57                            static_config.root.display()
58                        ),
59                        source: None,
60                    });
61                }
62
63                if !static_config.root.is_dir() {
64                    return Err(SentinelError::Config {
65                        message: format!(
66                            "Route '{}' static files root '{}' is not a directory.\n\
67                             The 'root' must be a directory path, not a file.",
68                            route.id,
69                            static_config.root.display()
70                        ),
71                        source: None,
72                    });
73                }
74            }
75        }
76
77        Ok(())
78    }
79
80    fn name(&self) -> &str {
81        "RouteValidator"
82    }
83}
84
85/// Upstream configuration validator
86///
87/// Performs runtime-specific upstream validation. The schema-level validation
88/// in sentinel-config handles most checks; this focuses on network reachability
89/// and runtime concerns.
90pub struct UpstreamValidator;
91
92#[async_trait::async_trait]
93impl ConfigValidator for UpstreamValidator {
94    async fn validate(&self, config: &Config) -> SentinelResult<()> {
95        // Most validation is now handled by Config's validate_config_semantics
96        // This validator handles runtime-specific checks
97
98        for (name, upstream) in &config.upstreams {
99            // Validate target addresses can be parsed (supports hostnames)
100            for (i, target) in upstream.targets.iter().enumerate() {
101                // Try as socket address first
102                if target.address.parse::<std::net::SocketAddr>().is_ok() {
103                    continue;
104                }
105
106                // Try as host:port format
107                let parts: Vec<&str> = target.address.rsplitn(2, ':').collect();
108                if parts.len() == 2 {
109                    if let Ok(port) = parts[0].parse::<u16>() {
110                        if port > 0 {
111                            continue; // Valid host:port format
112                        }
113                    }
114                }
115
116                return Err(SentinelError::Config {
117                    message: format!(
118                        "Upstream '{}' target #{} has invalid address '{}'.\n\
119                         \n\
120                         Expected format: HOST:PORT\n\
121                         \n\
122                         Valid examples:\n\
123                         - 127.0.0.1:8080 (IPv4)\n\
124                         - [::1]:8080 (IPv6)\n\
125                         - backend.local:8080 (hostname)\n\
126                         - api-server:3000 (service name)",
127                        name,
128                        i + 1,
129                        target.address
130                    ),
131                    source: None,
132                });
133            }
134
135            // Warn about upstreams with only one target (no redundancy)
136            if upstream.targets.len() == 1 {
137                tracing::warn!(
138                    upstream = %name,
139                    "Upstream '{}' has only one target. Consider adding more targets for redundancy.",
140                    name
141                );
142            }
143        }
144
145        Ok(())
146    }
147
148    fn name(&self) -> &str {
149        "UpstreamValidator"
150    }
151}