use crate::error::{ValidationError, ValidationErrorKind};
use crate::types::{
DeploymentSpec, EndpointSpec, EndpointTunnelConfig, Protocol, ResourceType, ScaleSpec,
ServiceSpec, ServiceType, TunnelAccessConfig, TunnelDefinition,
};
use cron::Schedule;
use std::collections::HashSet;
use std::str::FromStr;
fn make_validation_error(
code: &'static str,
message: impl Into<std::borrow::Cow<'static, str>>,
) -> validator::ValidationError {
let mut err = validator::ValidationError::new(code);
err.message = Some(message.into());
err
}
pub fn validate_version_wrapper(version: &str) -> Result<(), validator::ValidationError> {
if version == "v1" {
Ok(())
} else {
Err(make_validation_error(
"invalid_version",
format!("version must be 'v1', found '{version}'"),
))
}
}
pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
if name.len() < 3 || name.len() > 63 {
return Err(make_validation_error(
"invalid_deployment_name",
"deployment name must be 3-63 characters",
));
}
if let Some(first) = name.chars().next() {
if !first.is_ascii_alphanumeric() {
return Err(make_validation_error(
"invalid_deployment_name",
"deployment name must start with alphanumeric character",
));
}
}
for c in name.chars() {
if !c.is_ascii_alphanumeric() && c != '-' {
return Err(make_validation_error(
"invalid_deployment_name",
"deployment name can only contain alphanumeric characters and hyphens",
));
}
}
Ok(())
}
pub fn validate_image_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
if name.is_empty() || name.trim().is_empty() {
Err(make_validation_error(
"empty_image_name",
"image name cannot be empty",
))
} else {
Ok(())
}
}
pub fn validate_cpu_option_wrapper(cpu: f64) -> Result<(), validator::ValidationError> {
if cpu <= 0.0 {
Err(make_validation_error(
"invalid_cpu",
format!("CPU limit must be > 0, found {cpu}"),
))
} else {
Ok(())
}
}
pub fn validate_memory_option_wrapper(value: &String) -> Result<(), validator::ValidationError> {
const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
let suffix_match = VALID_SUFFIXES
.iter()
.find(|&&suffix| value.ends_with(suffix));
match suffix_match {
Some(suffix) => {
let numeric_part = &value[..value.len() - suffix.len()];
match numeric_part.parse::<u64>() {
Ok(n) if n > 0 => Ok(()),
_ => Err(make_validation_error(
"invalid_memory_format",
format!("invalid memory format: '{value}'"),
)),
}
}
None => Err(make_validation_error(
"invalid_memory_format",
format!("invalid memory format: '{value}' (use Ki, Mi, Gi, or Ti suffix)"),
)),
}
}
pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
if port >= 1 {
Ok(())
} else {
Err(make_validation_error(
"invalid_port",
"port must be between 1-65535",
))
}
}
pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
if let ScaleSpec::Adaptive { min, max, .. } = scale {
if *min > *max {
return Err(make_validation_error(
"invalid_scale_range",
format!("scale min ({min}) cannot be greater than max ({max})"),
));
}
}
Ok(())
}
pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
make_validation_error(
"invalid_cron_schedule",
format!("invalid cron schedule '{schedule}': {e}"),
)
})
}
pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
if !value.starts_with("$S:") {
return Ok(());
}
let secret_ref = &value[3..];
if secret_ref.is_empty() {
return Err(make_validation_error(
"invalid_secret_reference",
"secret reference cannot be empty after $S:",
));
}
let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
let parts: Vec<&str> = rest.splitn(2, '/').collect();
if parts.len() != 2 {
return Err(make_validation_error(
"invalid_secret_reference",
format!(
"cross-service secret reference '{value}' must have format @service/secret-name"
),
));
}
let service_name = parts[0];
let secret_name = parts[1];
if service_name.is_empty() {
return Err(make_validation_error(
"invalid_secret_reference",
format!("service name in secret reference '{value}' cannot be empty"),
));
}
if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
return Err(make_validation_error(
"invalid_secret_reference",
format!("service name in secret reference '{value}' must start with a letter"),
));
}
for c in service_name.chars() {
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
return Err(make_validation_error(
"invalid_secret_reference",
format!(
"service name in secret reference '{value}' contains invalid character '{c}'"
),
));
}
}
secret_name
} else {
secret_ref
};
if secret_name.is_empty() {
return Err(make_validation_error(
"invalid_secret_reference",
format!("secret name in '{value}' cannot be empty"),
));
}
let first_char = secret_name.chars().next().unwrap();
if !first_char.is_ascii_alphabetic() {
return Err(make_validation_error(
"invalid_secret_reference",
format!("secret name in '{value}' must start with a letter, found '{first_char}'"),
));
}
for c in secret_name.chars() {
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
return Err(make_validation_error(
"invalid_secret_reference",
format!(
"secret name in '{value}' contains invalid character '{c}' (only alphanumeric, hyphens, underscores allowed)"
),
));
}
}
Ok(())
}
#[allow(clippy::implicit_hasher)]
pub fn validate_env_vars(
service_name: &str,
env: &std::collections::HashMap<String, String>,
) -> Result<(), crate::error::ValidationError> {
for (key, value) in env {
if let Err(e) = validate_secret_reference(value) {
return Err(crate::error::ValidationError {
kind: crate::error::ValidationErrorKind::InvalidEnvVar {
key: key.clone(),
reason: e
.message
.map_or_else(|| "invalid secret reference".to_string(), |m| m.to_string()),
},
path: format!("services.{service_name}.env.{key}"),
});
}
}
Ok(())
}
pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
if !re.is_match(name) || name.len() > 63 {
return Err(make_validation_error(
"invalid_storage_name",
format!("storage name '{name}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen"),
));
}
Ok(())
}
pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
validate_storage_name(name)
}
pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
let service_names: HashSet<&str> = spec
.services
.keys()
.map(std::string::String::as_str)
.collect();
for (service_name, service_spec) in &spec.services {
for dep in &service_spec.depends {
if !service_names.contains(dep.service.as_str()) {
return Err(ValidationError {
kind: ValidationErrorKind::UnknownDependency {
service: dep.service.clone(),
},
path: format!("services.{service_name}.depends"),
});
}
}
}
Ok(())
}
pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
for (service_name, service_spec) in &spec.services {
let mut seen = HashSet::new();
for endpoint in &service_spec.endpoints {
if !seen.insert(&endpoint.name) {
return Err(ValidationError {
kind: ValidationErrorKind::DuplicateEndpoint {
name: endpoint.name.clone(),
},
path: format!("services.{service_name}.endpoints"),
});
}
}
}
Ok(())
}
pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
for (service_name, service_spec) in &spec.services {
validate_service_schedule(service_name, service_spec)?;
}
Ok(())
}
pub fn validate_service_schedule(
service_name: &str,
spec: &ServiceSpec,
) -> Result<(), ValidationError> {
if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
return Err(ValidationError {
kind: ValidationErrorKind::ScheduleOnlyForCron,
path: format!("services.{service_name}.schedule"),
});
}
if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
return Err(ValidationError {
kind: ValidationErrorKind::CronRequiresSchedule,
path: format!("services.{service_name}.schedule"),
});
}
Ok(())
}
pub fn validate_version(version: &str) -> Result<(), ValidationError> {
if version == "v1" {
Ok(())
} else {
Err(ValidationError {
kind: ValidationErrorKind::InvalidVersion {
found: version.to_string(),
},
path: "version".to_string(),
})
}
}
pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
if name.len() < 3 || name.len() > 63 {
return Err(ValidationError {
kind: ValidationErrorKind::EmptyDeploymentName,
path: "deployment".to_string(),
});
}
if let Some(first) = name.chars().next() {
if !first.is_ascii_alphanumeric() {
return Err(ValidationError {
kind: ValidationErrorKind::EmptyDeploymentName,
path: "deployment".to_string(),
});
}
}
for c in name.chars() {
if !c.is_ascii_alphanumeric() && c != '-' {
return Err(ValidationError {
kind: ValidationErrorKind::EmptyDeploymentName,
path: "deployment".to_string(),
});
}
}
Ok(())
}
pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
if name.is_empty() || name.trim().is_empty() {
Err(ValidationError {
kind: ValidationErrorKind::EmptyImageName,
path: "image.name".to_string(),
})
} else {
Ok(())
}
}
pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
if *cpu > 0.0 {
Ok(())
} else {
Err(ValidationError {
kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
path: "resources.cpu".to_string(),
})
}
}
pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
let suffix_match = VALID_SUFFIXES
.iter()
.find(|&&suffix| value.ends_with(suffix));
match suffix_match {
Some(suffix) => {
let numeric_part = &value[..value.len() - suffix.len()];
match numeric_part.parse::<u64>() {
Ok(n) if n > 0 => Ok(()),
_ => Err(ValidationError {
kind: ValidationErrorKind::InvalidMemoryFormat {
value: value.to_string(),
},
path: "resources.memory".to_string(),
}),
}
}
None => Err(ValidationError {
kind: ValidationErrorKind::InvalidMemoryFormat {
value: value.to_string(),
},
path: "resources.memory".to_string(),
}),
}
}
pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
if *port >= 1 {
Ok(())
} else {
Err(ValidationError {
kind: ValidationErrorKind::InvalidPort {
port: u32::from(*port),
},
path: "endpoints[].port".to_string(),
})
}
}
pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
let mut seen = HashSet::new();
for endpoint in endpoints {
if !seen.insert(&endpoint.name) {
return Err(ValidationError {
kind: ValidationErrorKind::DuplicateEndpoint {
name: endpoint.name.clone(),
},
path: "endpoints".to_string(),
});
}
}
Ok(())
}
pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
if min <= max {
Ok(())
} else {
Err(ValidationError {
kind: ValidationErrorKind::InvalidScaleRange { min, max },
path: "scale".to_string(),
})
}
}
pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
make_validation_error(
"invalid_tunnel_ttl",
format!("invalid TTL format '{ttl}': {e}"),
)
})
}
pub fn validate_tunnel_access_config(
config: &TunnelAccessConfig,
path: &str,
) -> Result<(), ValidationError> {
if let Some(ref max_ttl) = config.max_ttl {
validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
kind: ValidationErrorKind::InvalidTunnelTtl {
value: max_ttl.clone(),
reason: e
.message
.map_or_else(|| "invalid duration format".to_string(), |m| m.to_string()),
},
path: format!("{path}.access.max_ttl"),
})?;
}
Ok(())
}
pub fn validate_endpoint_tunnel_config(
config: &EndpointTunnelConfig,
path: &str,
) -> Result<(), ValidationError> {
if let Some(ref access) = config.access {
validate_tunnel_access_config(access, path)?;
}
Ok(())
}
pub fn validate_tunnel_definition(
name: &str,
tunnel: &TunnelDefinition,
) -> Result<(), ValidationError> {
let path = format!("tunnels.{name}");
if tunnel.local_port == 0 {
return Err(ValidationError {
kind: ValidationErrorKind::InvalidTunnelPort {
port: tunnel.local_port,
field: "local_port".to_string(),
},
path: format!("{path}.local_port"),
});
}
if tunnel.remote_port == 0 {
return Err(ValidationError {
kind: ValidationErrorKind::InvalidTunnelPort {
port: tunnel.remote_port,
field: "remote_port".to_string(),
},
path: format!("{path}.remote_port"),
});
}
Ok(())
}
pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
for (name, tunnel) in &spec.tunnels {
validate_tunnel_definition(name, tunnel)?;
}
for (service_name, service_spec) in &spec.services {
for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
if let Some(ref tunnel_config) = endpoint.tunnel {
let path = format!("services.{service_name}.endpoints[{idx}].tunnel");
validate_endpoint_tunnel_config(tunnel_config, &path)?;
}
}
}
Ok(())
}
pub fn validate_wasm_configs(spec: &DeploymentSpec) -> Result<(), ValidationError> {
for (service_name, service_spec) in &spec.services {
validate_wasm_config(service_name, service_spec)?;
}
Ok(())
}
pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
if !spec.service_type.is_wasm() && spec.wasm.is_some() {
return Err(ValidationError {
kind: ValidationErrorKind::WasmConfigOnNonWasmType,
path: format!("services.{service_name}.wasm"),
});
}
if let Some(ref wasm) = spec.wasm {
validate_wasm_fields(service_name, wasm)?;
validate_wasm_capabilities(service_name, spec, wasm)?;
validate_wasm_http_endpoints(service_name, spec)?;
validate_wasm_preopens(service_name, wasm)?;
}
Ok(())
}
fn validate_wasm_fields(
service_name: &str,
wasm: &crate::types::WasmConfig,
) -> Result<(), ValidationError> {
if let Some(ref max_mem) = wasm.max_memory {
validate_memory_format(max_mem).map_err(|_| ValidationError {
kind: ValidationErrorKind::InvalidMemoryFormat {
value: max_mem.clone(),
},
path: format!("services.{service_name}.wasm.max_memory"),
})?;
}
if wasm.min_instances > wasm.max_instances {
return Err(ValidationError {
kind: ValidationErrorKind::InvalidWasmInstanceRange {
min: wasm.min_instances,
max: wasm.max_instances,
},
path: format!("services.{service_name}.wasm"),
});
}
Ok(())
}
fn validate_wasm_capabilities(
service_name: &str,
spec: &ServiceSpec,
wasm: &crate::types::WasmConfig,
) -> Result<(), ValidationError> {
let Some(ref caps) = wasm.capabilities else {
return Ok(());
};
let Some(defaults) = spec.service_type.default_wasm_capabilities() else {
return Ok(());
};
let checks: &[(&str, bool, bool)] = &[
("config", caps.config, defaults.config),
("keyvalue", caps.keyvalue, defaults.keyvalue),
("logging", caps.logging, defaults.logging),
("secrets", caps.secrets, defaults.secrets),
("metrics", caps.metrics, defaults.metrics),
("http_client", caps.http_client, defaults.http_client),
("cli", caps.cli, defaults.cli),
("filesystem", caps.filesystem, defaults.filesystem),
("sockets", caps.sockets, defaults.sockets),
];
for &(cap_name, requested, default) in checks {
validate_capability_restriction(
service_name,
spec.service_type,
cap_name,
requested,
default,
)?;
}
Ok(())
}
fn validate_wasm_http_endpoints(
service_name: &str,
spec: &ServiceSpec,
) -> Result<(), ValidationError> {
if spec.service_type == ServiceType::WasmHttp && !spec.endpoints.is_empty() {
let has_http_endpoint = spec
.endpoints
.iter()
.any(|e| matches!(e.protocol, Protocol::Http | Protocol::Https));
if !has_http_endpoint {
return Err(ValidationError {
kind: ValidationErrorKind::WasmHttpMissingHttpEndpoint,
path: format!("services.{service_name}.endpoints"),
});
}
}
Ok(())
}
fn validate_wasm_preopens(
service_name: &str,
wasm: &crate::types::WasmConfig,
) -> Result<(), ValidationError> {
for (i, preopen) in wasm.preopens.iter().enumerate() {
if preopen.source.is_empty() {
return Err(ValidationError {
kind: ValidationErrorKind::WasmPreopenEmpty {
index: i,
field: "source".to_string(),
},
path: format!("services.{service_name}.wasm.preopens[{i}].source"),
});
}
if preopen.target.is_empty() {
return Err(ValidationError {
kind: ValidationErrorKind::WasmPreopenEmpty {
index: i,
field: "target".to_string(),
},
path: format!("services.{service_name}.wasm.preopens[{i}].target"),
});
}
}
Ok(())
}
fn validate_capability_restriction(
service_name: &str,
service_type: ServiceType,
cap_name: &str,
requested: bool,
default: bool,
) -> Result<(), ValidationError> {
if requested && !default {
return Err(ValidationError {
kind: ValidationErrorKind::WasmCapabilityNotAvailable {
capability: cap_name.to_string(),
service_type: format!("{service_type:?}"),
},
path: format!("services.{service_name}.wasm.capabilities.{cap_name}"),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExposeType, Protocol};
#[test]
fn test_validate_version_valid() {
assert!(validate_version("v1").is_ok());
}
#[test]
fn test_validate_version_invalid_v2() {
let result = validate_version("v2");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err.kind,
ValidationErrorKind::InvalidVersion { found } if found == "v2"
));
}
#[test]
fn test_validate_version_empty() {
let result = validate_version("");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err.kind,
ValidationErrorKind::InvalidVersion { found } if found.is_empty()
));
}
#[test]
fn test_validate_deployment_name_valid() {
assert!(validate_deployment_name("my-app").is_ok());
assert!(validate_deployment_name("api").is_ok());
assert!(validate_deployment_name("my-service-123").is_ok());
assert!(validate_deployment_name("a1b").is_ok());
}
#[test]
fn test_validate_deployment_name_too_short() {
assert!(validate_deployment_name("ab").is_err());
assert!(validate_deployment_name("a").is_err());
assert!(validate_deployment_name("").is_err());
}
#[test]
fn test_validate_deployment_name_too_long() {
let long_name = "a".repeat(64);
assert!(validate_deployment_name(&long_name).is_err());
}
#[test]
fn test_validate_deployment_name_invalid_chars() {
assert!(validate_deployment_name("my_app").is_err()); assert!(validate_deployment_name("my.app").is_err()); assert!(validate_deployment_name("my app").is_err()); assert!(validate_deployment_name("my@app").is_err()); }
#[test]
fn test_validate_deployment_name_must_start_alphanumeric() {
assert!(validate_deployment_name("-myapp").is_err());
assert!(validate_deployment_name("_myapp").is_err());
}
#[test]
fn test_validate_image_name_valid() {
assert!(validate_image_name("nginx:latest").is_ok());
assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
assert!(validate_image_name("ubuntu").is_ok());
}
#[test]
fn test_validate_image_name_empty() {
let result = validate_image_name("");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::EmptyImageName
));
}
#[test]
fn test_validate_image_name_whitespace_only() {
assert!(validate_image_name(" ").is_err());
assert!(validate_image_name("\t\n").is_err());
}
#[test]
fn test_validate_cpu_valid() {
assert!(validate_cpu(&0.5).is_ok());
assert!(validate_cpu(&1.0).is_ok());
assert!(validate_cpu(&2.0).is_ok());
assert!(validate_cpu(&0.001).is_ok());
}
#[test]
fn test_validate_cpu_zero() {
let result = validate_cpu(&0.0);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
));
}
#[test]
#[allow(clippy::float_cmp)]
fn test_validate_cpu_negative() {
let result = validate_cpu(&-1.0);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
));
}
#[test]
fn test_validate_memory_format_valid() {
assert!(validate_memory_format("512Mi").is_ok());
assert!(validate_memory_format("1Gi").is_ok());
assert!(validate_memory_format("2Ti").is_ok());
assert!(validate_memory_format("256Ki").is_ok());
assert!(validate_memory_format("4096Mi").is_ok());
}
#[test]
fn test_validate_memory_format_invalid_suffix() {
assert!(validate_memory_format("512MB").is_err());
assert!(validate_memory_format("1GB").is_err());
assert!(validate_memory_format("512").is_err());
assert!(validate_memory_format("512m").is_err());
}
#[test]
fn test_validate_memory_format_no_number() {
assert!(validate_memory_format("Mi").is_err());
assert!(validate_memory_format("Gi").is_err());
}
#[test]
fn test_validate_memory_format_invalid_number() {
assert!(validate_memory_format("-512Mi").is_err());
assert!(validate_memory_format("0Mi").is_err());
assert!(validate_memory_format("abcMi").is_err());
}
#[test]
fn test_validate_port_valid() {
assert!(validate_port(&1).is_ok());
assert!(validate_port(&80).is_ok());
assert!(validate_port(&443).is_ok());
assert!(validate_port(&8080).is_ok());
assert!(validate_port(&65535).is_ok());
}
#[test]
fn test_validate_port_zero() {
let result = validate_port(&0);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::InvalidPort { port } if port == 0
));
}
#[test]
fn test_validate_unique_endpoints_valid() {
let endpoints = vec![
EndpointSpec {
name: "http".to_string(),
protocol: Protocol::Http,
port: 8080,
target_port: None,
path: None,
host: None,
expose: ExposeType::Public,
stream: None,
tunnel: None,
},
EndpointSpec {
name: "grpc".to_string(),
protocol: Protocol::Tcp,
port: 9090,
target_port: None,
path: None,
host: None,
expose: ExposeType::Internal,
stream: None,
tunnel: None,
},
];
assert!(validate_unique_endpoints(&endpoints).is_ok());
}
#[test]
fn test_validate_unique_endpoints_empty() {
let endpoints: Vec<EndpointSpec> = vec![];
assert!(validate_unique_endpoints(&endpoints).is_ok());
}
#[test]
fn test_validate_unique_endpoints_duplicates() {
let endpoints = vec![
EndpointSpec {
name: "http".to_string(),
protocol: Protocol::Http,
port: 8080,
target_port: None,
path: None,
host: None,
expose: ExposeType::Public,
stream: None,
tunnel: None,
},
EndpointSpec {
name: "http".to_string(), protocol: Protocol::Https,
port: 8443,
target_port: None,
path: None,
host: None,
expose: ExposeType::Public,
stream: None,
tunnel: None,
},
];
let result = validate_unique_endpoints(&endpoints);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
));
}
#[test]
fn test_validate_scale_range_valid() {
assert!(validate_scale_range(1, 10).is_ok());
assert!(validate_scale_range(1, 1).is_ok()); assert!(validate_scale_range(0, 5).is_ok());
assert!(validate_scale_range(5, 100).is_ok());
}
#[test]
fn test_validate_scale_range_min_greater_than_max() {
let result = validate_scale_range(10, 5);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err.kind,
ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
));
}
#[test]
fn test_validate_scale_range_large_gap() {
assert!(validate_scale_range(1, 1000).is_ok());
}
#[test]
fn test_validate_schedule_wrapper_valid() {
assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
}
#[test]
fn test_validate_schedule_wrapper_invalid() {
assert!(validate_schedule_wrapper(&String::new()).is_err()); assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
}
#[test]
fn test_validate_secret_reference_plain_values() {
assert!(validate_secret_reference("my-value").is_ok());
assert!(validate_secret_reference("").is_ok());
assert!(validate_secret_reference("some string").is_ok());
assert!(validate_secret_reference("$E:MY_VAR").is_ok()); }
#[test]
fn test_validate_secret_reference_valid() {
assert!(validate_secret_reference("$S:my-secret").is_ok());
assert!(validate_secret_reference("$S:api_key").is_ok());
assert!(validate_secret_reference("$S:MySecret123").is_ok());
assert!(validate_secret_reference("$S:a").is_ok()); }
#[test]
fn test_validate_secret_reference_cross_service() {
assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
assert!(validate_secret_reference("$S:@svc/secret").is_ok());
}
#[test]
fn test_validate_secret_reference_empty_after_prefix() {
assert!(validate_secret_reference("$S:").is_err());
}
#[test]
fn test_validate_secret_reference_must_start_with_letter() {
assert!(validate_secret_reference("$S:123-secret").is_err());
assert!(validate_secret_reference("$S:-my-secret").is_err());
assert!(validate_secret_reference("$S:_underscore").is_err());
}
#[test]
fn test_validate_secret_reference_invalid_chars() {
assert!(validate_secret_reference("$S:my.secret").is_err());
assert!(validate_secret_reference("$S:my secret").is_err());
assert!(validate_secret_reference("$S:my@secret").is_err());
}
#[test]
fn test_validate_secret_reference_cross_service_invalid() {
assert!(validate_secret_reference("$S:@service").is_err());
assert!(validate_secret_reference("$S:@/secret").is_err());
assert!(validate_secret_reference("$S:@service/").is_err());
assert!(validate_secret_reference("$S:@123-service/secret").is_err());
}
#[test]
fn test_validate_tunnel_ttl_valid() {
assert!(validate_tunnel_ttl("30m").is_ok());
assert!(validate_tunnel_ttl("4h").is_ok());
assert!(validate_tunnel_ttl("1d").is_ok());
assert!(validate_tunnel_ttl("1h 30m").is_ok());
assert!(validate_tunnel_ttl("2h30m").is_ok());
}
#[test]
fn test_validate_tunnel_ttl_invalid() {
assert!(validate_tunnel_ttl("").is_err());
assert!(validate_tunnel_ttl("invalid").is_err());
assert!(validate_tunnel_ttl("30").is_err()); assert!(validate_tunnel_ttl("-1h").is_err()); }
#[test]
fn test_validate_tunnel_definition_valid() {
let tunnel = TunnelDefinition {
from: "node-a".to_string(),
to: "node-b".to_string(),
local_port: 8080,
remote_port: 9000,
protocol: crate::types::TunnelProtocol::Tcp,
expose: ExposeType::Internal,
};
assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
}
#[test]
fn test_validate_tunnel_definition_local_port_zero() {
let tunnel = TunnelDefinition {
from: "node-a".to_string(),
to: "node-b".to_string(),
local_port: 0,
remote_port: 9000,
protocol: crate::types::TunnelProtocol::Tcp,
expose: ExposeType::Internal,
};
let result = validate_tunnel_definition("test-tunnel", &tunnel);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
));
}
#[test]
fn test_validate_tunnel_definition_remote_port_zero() {
let tunnel = TunnelDefinition {
from: "node-a".to_string(),
to: "node-b".to_string(),
local_port: 8080,
remote_port: 0,
protocol: crate::types::TunnelProtocol::Tcp,
expose: ExposeType::Internal,
};
let result = validate_tunnel_definition("test-tunnel", &tunnel);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
));
}
#[test]
fn test_validate_endpoint_tunnel_config_valid() {
let config = EndpointTunnelConfig {
enabled: true,
from: Some("node-1".to_string()),
to: Some("ingress".to_string()),
remote_port: 8080,
expose: Some(ExposeType::Public),
access: None,
};
assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
}
#[test]
fn test_validate_endpoint_tunnel_config_with_access() {
let config = EndpointTunnelConfig {
enabled: true,
from: None,
to: None,
remote_port: 0, expose: None,
access: Some(TunnelAccessConfig {
enabled: true,
max_ttl: Some("4h".to_string()),
audit: true,
}),
};
assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
}
#[test]
fn test_validate_endpoint_tunnel_config_invalid_ttl() {
let config = EndpointTunnelConfig {
enabled: true,
from: None,
to: None,
remote_port: 0,
expose: None,
access: Some(TunnelAccessConfig {
enabled: true,
max_ttl: Some("invalid".to_string()),
audit: false,
}),
};
let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::InvalidTunnelTtl { .. }
));
}
#[test]
fn test_validate_capability_restriction_allowed() {
let result = validate_capability_restriction(
"test-svc",
ServiceType::WasmHttp,
"config",
true,
true,
);
assert!(result.is_ok());
}
#[test]
fn test_validate_capability_restriction_restricting_is_ok() {
let result = validate_capability_restriction(
"test-svc",
ServiceType::WasmHttp,
"config",
false,
true,
);
assert!(result.is_ok());
}
#[test]
fn test_validate_capability_restriction_granting_not_allowed() {
let result = validate_capability_restriction(
"test-svc",
ServiceType::WasmHttp,
"secrets",
true,
false,
);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err().kind,
ValidationErrorKind::WasmCapabilityNotAvailable { ref capability, .. }
if capability == "secrets"
));
}
#[test]
fn test_validate_capability_restriction_both_false_is_ok() {
let result = validate_capability_restriction(
"test-svc",
ServiceType::WasmTransformer,
"sockets",
false,
false,
);
assert!(result.is_ok());
}
}