use std::path::Path;
use super::{ErrorCategory, ValidationError, ValidationResult, ValidationWarning};
use crate::agents::AgentTransport;
use crate::filters::Filter;
use crate::Config;
pub async fn validate_agents(config: &Config) -> ValidationResult {
let mut result = ValidationResult::new();
let mut filter_names = std::collections::HashSet::new();
for route in &config.routes {
for filter_name in &route.filters {
filter_names.insert(filter_name.clone());
}
}
for filter_name in &filter_names {
let Some(filter_config) = config.filters.get(filter_name) else {
result.add_error(ValidationError::new(
ErrorCategory::Agent,
format!(
"Filter '{}' referenced in route but not defined",
filter_name
),
));
continue;
};
if let Filter::Agent(agent_filter) = &filter_config.filter {
let agent_id = &agent_filter.agent;
let Some(agent_config) = config.agents.iter().find(|a| &a.id == agent_id) else {
result.add_error(ValidationError::new(
ErrorCategory::Agent,
format!(
"Filter '{}' references agent '{}' which is not defined",
filter_name, agent_id
),
));
continue;
};
match &agent_config.transport {
AgentTransport::UnixSocket { path } => {
validate_unix_socket(&mut result, agent_id, path);
}
AgentTransport::Grpc { address, .. } => {
validate_grpc_address(&mut result, agent_id, address);
}
AgentTransport::Http { url, .. } => {
validate_http_url(&mut result, agent_id, url);
}
}
}
}
for agent_config in &config.agents {
match &agent_config.transport {
AgentTransport::UnixSocket { path } => {
if !path.exists() {
let is_referenced = config.filters.values().any(|f| {
if let Filter::Agent(af) = &f.filter {
af.agent == agent_config.id
} else {
false
}
});
if !is_referenced {
result.add_warning(ValidationWarning::new(format!(
"Agent '{}' socket path '{}' does not exist (agent not referenced by any filter)",
agent_config.id,
path.display()
)));
}
}
}
AgentTransport::Grpc { address, .. } => {
if !is_valid_grpc_address(address) {
result.add_warning(ValidationWarning::new(format!(
"Agent '{}' has invalid gRPC address format: '{}'",
agent_config.id, address
)));
}
}
AgentTransport::Http { url, .. } => {
if !is_valid_http_url(url) {
result.add_warning(ValidationWarning::new(format!(
"Agent '{}' has invalid HTTP URL format: '{}'",
agent_config.id, url
)));
}
}
}
}
result
}
fn validate_unix_socket(result: &mut ValidationResult, agent_id: &str, path: &Path) {
if !path.exists() {
result.add_error(ValidationError::new(
ErrorCategory::Agent,
format!(
"Agent '{}' socket path '{}' does not exist",
agent_id,
path.display()
),
));
} else if !is_socket_file(path) {
result.add_error(ValidationError::new(
ErrorCategory::Agent,
format!(
"Agent '{}' path '{}' exists but is not a socket",
agent_id,
path.display()
),
));
}
}
fn is_socket_file(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
path.metadata()
.map(|m| m.file_type().is_socket())
.unwrap_or(false)
}
#[cfg(not(unix))]
{
let _ = path;
true
}
}
fn validate_grpc_address(result: &mut ValidationResult, agent_id: &str, address: &str) {
if !is_valid_grpc_address(address) {
result.add_error(ValidationError::new(
ErrorCategory::Agent,
format!(
"Agent '{}' has invalid gRPC address: '{}'. Expected format: 'http://host:port' or 'https://host:port'",
agent_id, address
),
));
}
}
fn is_valid_grpc_address(address: &str) -> bool {
if let Ok(url) = url::Url::parse(address) {
matches!(url.scheme(), "http" | "https") && url.host().is_some()
} else {
false
}
}
fn validate_http_url(result: &mut ValidationResult, agent_id: &str, url_str: &str) {
if !is_valid_http_url(url_str) {
result.add_error(ValidationError::new(
ErrorCategory::Agent,
format!(
"Agent '{}' has invalid HTTP URL: '{}'. Expected format: 'http://host:port/path' or 'https://host:port/path'",
agent_id, url_str
),
));
}
}
fn is_valid_http_url(url_str: &str) -> bool {
if let Ok(url) = url::Url::parse(url_str) {
matches!(url.scheme(), "http" | "https") && url.host().is_some()
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::{AgentConfig, AgentEvent, AgentType};
use crate::filters::{AgentFilter, FilterConfig};
use crate::{MatchCondition, RouteConfig, RoutePolicies, ServiceType};
use std::path::PathBuf;
use zentinel_common::types::Priority;
fn test_route_with_filter(filter: &str) -> RouteConfig {
RouteConfig {
id: "test".to_string(),
priority: Priority::NORMAL,
matches: vec![MatchCondition::PathPrefix("/".to_string())],
upstream: None,
service_type: ServiceType::Web,
policies: RoutePolicies::default(),
filters: vec![filter.to_string()],
builtin_handler: None,
waf_enabled: false,
circuit_breaker: None,
retry_policy: None,
static_files: None,
api_schema: None,
error_pages: None,
websocket: false,
websocket_inspection: false,
inference: None,
shadow: None,
fallback: None,
}
}
fn test_agent_config(id: &str, transport: AgentTransport) -> AgentConfig {
AgentConfig {
id: id.to_string(),
agent_type: AgentType::Custom("test".to_string()),
transport,
events: vec![AgentEvent::RequestHeaders],
pool: None,
timeout_ms: 1000,
failure_mode: crate::FailureMode::default(),
circuit_breaker: None,
max_request_body_bytes: None,
max_response_body_bytes: None,
request_body_mode: Default::default(),
response_body_mode: Default::default(),
chunk_timeout_ms: 5000,
config: None,
max_concurrent_calls: 100,
}
}
#[tokio::test]
async fn test_validate_missing_filter() {
let mut config = Config::default_for_testing();
config.routes = vec![test_route_with_filter("nonexistent-filter")];
let result = validate_agents(&config).await;
assert!(!result.errors.is_empty());
assert!(result
.errors
.iter()
.any(|e| e.message.contains("not defined")));
}
#[tokio::test]
async fn test_validate_agent_filter_missing_agent() {
let mut config = Config::default_for_testing();
config.routes = vec![test_route_with_filter("auth-filter")];
config.filters.insert(
"auth-filter".to_string(),
FilterConfig::new(
"auth-filter",
Filter::Agent(AgentFilter::new("nonexistent-agent")),
),
);
let result = validate_agents(&config).await;
assert!(!result.errors.is_empty());
assert!(result
.errors
.iter()
.any(|e| e.message.contains("nonexistent-agent") && e.message.contains("not defined")));
}
#[tokio::test]
async fn test_validate_unix_socket_missing() {
let mut config = Config::default_for_testing();
config.routes = vec![test_route_with_filter("auth-filter")];
config.filters.insert(
"auth-filter".to_string(),
FilterConfig::new("auth-filter", Filter::Agent(AgentFilter::new("auth-agent"))),
);
config.agents.push(test_agent_config(
"auth-agent",
AgentTransport::UnixSocket {
path: PathBuf::from("/nonexistent/path/to/socket.sock"),
},
));
let result = validate_agents(&config).await;
assert!(!result.errors.is_empty());
assert!(result
.errors
.iter()
.any(|e| e.message.contains("does not exist")));
}
#[tokio::test]
async fn test_validate_grpc_invalid_address() {
let mut config = Config::default_for_testing();
config.routes = vec![test_route_with_filter("grpc-filter")];
config.filters.insert(
"grpc-filter".to_string(),
FilterConfig::new("grpc-filter", Filter::Agent(AgentFilter::new("grpc-agent"))),
);
config.agents.push(test_agent_config(
"grpc-agent",
AgentTransport::Grpc {
address: "invalid-address".to_string(),
tls: None,
},
));
let result = validate_agents(&config).await;
assert!(!result.errors.is_empty());
assert!(result
.errors
.iter()
.any(|e| e.message.contains("invalid gRPC address")));
}
#[tokio::test]
async fn test_validate_grpc_valid_address() {
let mut config = Config::default_for_testing();
config.routes = vec![test_route_with_filter("grpc-filter")];
config.filters.insert(
"grpc-filter".to_string(),
FilterConfig::new("grpc-filter", Filter::Agent(AgentFilter::new("grpc-agent"))),
);
config.agents.push(test_agent_config(
"grpc-agent",
AgentTransport::Grpc {
address: "http://localhost:50051".to_string(),
tls: None,
},
));
let result = validate_agents(&config).await;
assert!(
result.errors.is_empty(),
"Expected no errors but got: {:?}",
result.errors
);
}
#[tokio::test]
async fn test_validate_http_invalid_url() {
let mut config = Config::default_for_testing();
config.routes = vec![test_route_with_filter("http-filter")];
config.filters.insert(
"http-filter".to_string(),
FilterConfig::new("http-filter", Filter::Agent(AgentFilter::new("http-agent"))),
);
config.agents.push(test_agent_config(
"http-agent",
AgentTransport::Http {
url: "not-a-valid-url".to_string(),
tls: None,
},
));
let result = validate_agents(&config).await;
assert!(!result.errors.is_empty());
assert!(result
.errors
.iter()
.any(|e| e.message.contains("invalid HTTP URL")));
}
#[tokio::test]
async fn test_validate_http_valid_url() {
let mut config = Config::default_for_testing();
config.routes = vec![test_route_with_filter("http-filter")];
config.filters.insert(
"http-filter".to_string(),
FilterConfig::new("http-filter", Filter::Agent(AgentFilter::new("http-agent"))),
);
config.agents.push(test_agent_config(
"http-agent",
AgentTransport::Http {
url: "http://localhost:8080/agent".to_string(),
tls: None,
},
));
let result = validate_agents(&config).await;
assert!(
result.errors.is_empty(),
"Expected no errors but got: {:?}",
result.errors
);
}
#[test]
fn test_is_valid_grpc_address() {
assert!(is_valid_grpc_address("http://localhost:50051"));
assert!(is_valid_grpc_address("https://agent.example.com:443"));
assert!(is_valid_grpc_address("http://192.168.1.100:50051"));
assert!(!is_valid_grpc_address("localhost:50051")); assert!(!is_valid_grpc_address("invalid"));
assert!(!is_valid_grpc_address("ftp://localhost:21")); }
#[test]
fn test_is_valid_http_url() {
assert!(is_valid_http_url("http://localhost:8080/agent"));
assert!(is_valid_http_url(
"https://agent.example.com/api/v1/process"
));
assert!(is_valid_http_url("http://192.168.1.100:8080"));
assert!(!is_valid_http_url("localhost:8080/agent")); assert!(!is_valid_http_url("not-a-url"));
assert!(!is_valid_http_url("ftp://files.example.com")); }
}