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