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!(
122            upstream_count = config.upstreams.len(),
123            "Running upstream validator"
124        );
125
126        // Most validation is now handled by Config's validate_config_semantics
127        // This validator handles runtime-specific checks
128
129        for (name, upstream) in &config.upstreams {
130            trace!(
131                upstream_id = %name,
132                target_count = upstream.targets.len(),
133                "Validating upstream"
134            );
135
136            // Validate target addresses can be parsed (supports hostnames)
137            for (i, target) in upstream.targets.iter().enumerate() {
138                trace!(
139                    upstream_id = %name,
140                    target_index = i,
141                    address = %target.address,
142                    "Validating target address"
143                );
144
145                // Try as socket address first
146                if target.address.parse::<std::net::SocketAddr>().is_ok() {
147                    continue;
148                }
149
150                // Try as host:port format
151                let parts: Vec<&str> = target.address.rsplitn(2, ':').collect();
152                if parts.len() == 2 {
153                    if let Ok(port) = parts[0].parse::<u16>() {
154                        if port > 0 {
155                            continue; // Valid host:port format
156                        }
157                    }
158                }
159
160                warn!(
161                    upstream_id = %name,
162                    target_index = i,
163                    address = %target.address,
164                    "Invalid target address format"
165                );
166
167                return Err(SentinelError::Config {
168                    message: format!(
169                        "Upstream '{}' target #{} has invalid address '{}'.\n\
170                         \n\
171                         Expected format: HOST:PORT\n\
172                         \n\
173                         Valid examples:\n\
174                         - 127.0.0.1:8080 (IPv4)\n\
175                         - [::1]:8080 (IPv6)\n\
176                         - backend.local:8080 (hostname)\n\
177                         - api-server:3000 (service name)",
178                        name,
179                        i + 1,
180                        target.address
181                    ),
182                    source: None,
183                });
184            }
185
186            // Warn about upstreams with only one target (no redundancy)
187            if upstream.targets.len() == 1 {
188                warn!(
189                    upstream_id = %name,
190                    "Upstream has only one target - consider adding more for redundancy"
191                );
192            }
193        }
194
195        debug!("Upstream validation passed");
196        Ok(())
197    }
198
199    fn name(&self) -> &str {
200        "UpstreamValidator"
201    }
202}