Skip to main content

zlayer_spec/
validate.rs

1//! Validation functions for ZLayer deployment specifications
2//!
3//! This module provides validators for all spec fields with proper error reporting.
4
5use crate::error::{ValidationError, ValidationErrorKind};
6use crate::types::{
7    DeploymentSpec, EndpointSpec, EndpointTunnelConfig, ResourceType, ScaleSpec, ServiceSpec,
8    TunnelAccessConfig, TunnelDefinition,
9};
10use cron::Schedule;
11use std::collections::HashSet;
12use std::str::FromStr;
13
14// =============================================================================
15// Validator crate wrapper functions
16// =============================================================================
17// These functions match the signature expected by #[validate(custom(function = "..."))]
18// They return Result<(), validator::ValidationError>
19
20fn make_validation_error(
21    code: &'static str,
22    message: impl Into<std::borrow::Cow<'static, str>>,
23) -> validator::ValidationError {
24    let mut err = validator::ValidationError::new(code);
25    err.message = Some(message.into());
26    err
27}
28
29/// Wrapper for validate_version for use with validator crate
30pub fn validate_version_wrapper(version: &str) -> Result<(), validator::ValidationError> {
31    if version == "v1" {
32        Ok(())
33    } else {
34        Err(make_validation_error(
35            "invalid_version",
36            format!("version must be 'v1', found '{}'", version),
37        ))
38    }
39}
40
41/// Wrapper for validate_deployment_name for use with validator crate
42pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
43    // Check length
44    if name.len() < 3 || name.len() > 63 {
45        return Err(make_validation_error(
46            "invalid_deployment_name",
47            "deployment name must be 3-63 characters",
48        ));
49    }
50
51    // Check first character is alphanumeric
52    if let Some(first) = name.chars().next() {
53        if !first.is_ascii_alphanumeric() {
54            return Err(make_validation_error(
55                "invalid_deployment_name",
56                "deployment name must start with alphanumeric character",
57            ));
58        }
59    }
60
61    // Check all characters are alphanumeric or hyphens
62    for c in name.chars() {
63        if !c.is_ascii_alphanumeric() && c != '-' {
64            return Err(make_validation_error(
65                "invalid_deployment_name",
66                "deployment name can only contain alphanumeric characters and hyphens",
67            ));
68        }
69    }
70
71    Ok(())
72}
73
74/// Wrapper for validate_image_name for use with validator crate
75pub fn validate_image_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
76    if name.is_empty() || name.trim().is_empty() {
77        Err(make_validation_error(
78            "empty_image_name",
79            "image name cannot be empty",
80        ))
81    } else {
82        Ok(())
83    }
84}
85
86/// Wrapper for validate_cpu for use with validator crate
87/// Note: For Option<f64> fields, validator crate unwraps and passes the inner f64
88pub fn validate_cpu_option_wrapper(cpu: f64) -> Result<(), validator::ValidationError> {
89    if cpu <= 0.0 {
90        Err(make_validation_error(
91            "invalid_cpu",
92            format!("CPU limit must be > 0, found {}", cpu),
93        ))
94    } else {
95        Ok(())
96    }
97}
98
99/// Wrapper for validate_memory_format for use with validator crate
100/// Note: For Option<String> fields, validator crate unwraps and passes &String
101pub fn validate_memory_option_wrapper(value: &String) -> Result<(), validator::ValidationError> {
102    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
103
104    let suffix_match = VALID_SUFFIXES
105        .iter()
106        .find(|&&suffix| value.ends_with(suffix));
107
108    match suffix_match {
109        Some(suffix) => {
110            let numeric_part = &value[..value.len() - suffix.len()];
111            match numeric_part.parse::<u64>() {
112                Ok(n) if n > 0 => Ok(()),
113                _ => Err(make_validation_error(
114                    "invalid_memory_format",
115                    format!("invalid memory format: '{}'", value),
116                )),
117            }
118        }
119        None => Err(make_validation_error(
120            "invalid_memory_format",
121            format!(
122                "invalid memory format: '{}' (use Ki, Mi, Gi, or Ti suffix)",
123                value
124            ),
125        )),
126    }
127}
128
129/// Wrapper for validate_port for use with validator crate
130/// Note: validator crate passes primitive types by value for custom validators
131pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
132    if port >= 1 {
133        Ok(())
134    } else {
135        Err(make_validation_error(
136            "invalid_port",
137            "port must be between 1-65535",
138        ))
139    }
140}
141
142/// Validate scale range (min <= max) for ScaleSpec
143pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
144    if let ScaleSpec::Adaptive { min, max, .. } = scale {
145        if *min > *max {
146            return Err(make_validation_error(
147                "invalid_scale_range",
148                format!("scale min ({}) cannot be greater than max ({})", min, max),
149            ));
150        }
151    }
152    Ok(())
153}
154
155/// Wrapper for validate_cron_schedule for use with validator crate
156/// Note: For Option<String> fields, validator crate unwraps and passes &String
157pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
158    Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
159        make_validation_error(
160            "invalid_cron_schedule",
161            format!("invalid cron schedule '{}': {}", schedule, e),
162        )
163    })
164}
165
166/// Validate a secret reference name format
167///
168/// Secret names must:
169/// - Start with a letter (a-z, A-Z)
170/// - Contain only alphanumeric characters, hyphens, and underscores
171/// - Optionally be prefixed with `@service/` for cross-service references
172///
173/// Examples of valid secret refs:
174/// - `$S:my-secret`
175/// - `$S:api_key`
176/// - `$S:@auth-service/jwt-secret`
177pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
178    // Only validate values that start with $S:
179    if !value.starts_with("$S:") {
180        return Ok(());
181    }
182
183    let secret_ref = &value[3..]; // Remove "$S:" prefix
184
185    if secret_ref.is_empty() {
186        return Err(make_validation_error(
187            "invalid_secret_reference",
188            "secret reference cannot be empty after $S:",
189        ));
190    }
191
192    // Check for cross-service reference format: @service/secret-name
193    let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
194        // Cross-service reference: @service/secret-name
195        let parts: Vec<&str> = rest.splitn(2, '/').collect();
196        if parts.len() != 2 {
197            return Err(make_validation_error(
198                "invalid_secret_reference",
199                format!(
200                    "cross-service secret reference '{}' must have format @service/secret-name",
201                    value
202                ),
203            ));
204        }
205
206        let service_name = parts[0];
207        let secret_name = parts[1];
208
209        // Validate service name part
210        if service_name.is_empty() {
211            return Err(make_validation_error(
212                "invalid_secret_reference",
213                format!(
214                    "service name in secret reference '{}' cannot be empty",
215                    value
216                ),
217            ));
218        }
219
220        if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
221            return Err(make_validation_error(
222                "invalid_secret_reference",
223                format!(
224                    "service name in secret reference '{}' must start with a letter",
225                    value
226                ),
227            ));
228        }
229
230        for c in service_name.chars() {
231            if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
232                return Err(make_validation_error(
233                    "invalid_secret_reference",
234                    format!(
235                        "service name in secret reference '{}' contains invalid character '{}'",
236                        value, c
237                    ),
238                ));
239            }
240        }
241
242        secret_name
243    } else {
244        secret_ref
245    };
246
247    // Validate the secret name
248    if secret_name.is_empty() {
249        return Err(make_validation_error(
250            "invalid_secret_reference",
251            format!("secret name in '{}' cannot be empty", value),
252        ));
253    }
254
255    // Must start with a letter
256    let first_char = secret_name.chars().next().unwrap();
257    if !first_char.is_ascii_alphabetic() {
258        return Err(make_validation_error(
259            "invalid_secret_reference",
260            format!(
261                "secret name in '{}' must start with a letter, found '{}'",
262                value, first_char
263            ),
264        ));
265    }
266
267    // All characters must be alphanumeric, hyphen, or underscore
268    for c in secret_name.chars() {
269        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
270            return Err(make_validation_error(
271                "invalid_secret_reference",
272                format!(
273                    "secret name in '{}' contains invalid character '{}' (only alphanumeric, hyphens, underscores allowed)",
274                    value, c
275                ),
276            ));
277        }
278    }
279
280    Ok(())
281}
282
283/// Validate all environment variable values in a service spec
284pub fn validate_env_vars(
285    service_name: &str,
286    env: &std::collections::HashMap<String, String>,
287) -> Result<(), crate::error::ValidationError> {
288    for (key, value) in env {
289        if let Err(e) = validate_secret_reference(value) {
290            return Err(crate::error::ValidationError {
291                kind: crate::error::ValidationErrorKind::InvalidEnvVar {
292                    key: key.clone(),
293                    reason: e
294                        .message
295                        .map(|m| m.to_string())
296                        .unwrap_or_else(|| "invalid secret reference".to_string()),
297                },
298                path: format!("services.{}.env.{}", service_name, key),
299            });
300        }
301    }
302    Ok(())
303}
304
305/// Validate storage name format (lowercase alphanumeric with hyphens)
306pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
307    // Must be lowercase alphanumeric with hyphens, not starting/ending with hyphen
308    let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
309    if !re.is_match(name) || name.len() > 63 {
310        return Err(make_validation_error(
311            "invalid_storage_name",
312            format!("storage name '{}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen", name),
313        ));
314    }
315    Ok(())
316}
317
318/// Wrapper for validate_storage_name for use with validator crate
319pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
320    validate_storage_name(name)
321}
322
323// =============================================================================
324// Cross-field validation functions (called from lib.rs)
325// =============================================================================
326
327/// Validate that all dependency service references exist
328pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
329    let service_names: HashSet<&str> = spec.services.keys().map(|s| s.as_str()).collect();
330
331    for (service_name, service_spec) in &spec.services {
332        for dep in &service_spec.depends {
333            if !service_names.contains(dep.service.as_str()) {
334                return Err(ValidationError {
335                    kind: ValidationErrorKind::UnknownDependency {
336                        service: dep.service.clone(),
337                    },
338                    path: format!("services.{}.depends", service_name),
339                });
340            }
341        }
342    }
343
344    Ok(())
345}
346
347/// Validate that each service has unique endpoint names
348pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
349    for (service_name, service_spec) in &spec.services {
350        let mut seen = HashSet::new();
351        for endpoint in &service_spec.endpoints {
352            if !seen.insert(&endpoint.name) {
353                return Err(ValidationError {
354                    kind: ValidationErrorKind::DuplicateEndpoint {
355                        name: endpoint.name.clone(),
356                    },
357                    path: format!("services.{}.endpoints", service_name),
358                });
359            }
360        }
361    }
362
363    Ok(())
364}
365
366/// Validate schedule/rtype consistency for all services
367pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
368    for (service_name, service_spec) in &spec.services {
369        validate_service_schedule(service_name, service_spec)?;
370    }
371    Ok(())
372}
373
374/// Validate schedule/rtype consistency for a single service
375pub fn validate_service_schedule(
376    service_name: &str,
377    spec: &ServiceSpec,
378) -> Result<(), ValidationError> {
379    // If schedule is set, rtype must be Cron
380    if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
381        return Err(ValidationError {
382            kind: ValidationErrorKind::ScheduleOnlyForCron,
383            path: format!("services.{}.schedule", service_name),
384        });
385    }
386
387    // If rtype is Cron, schedule must be set
388    if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
389        return Err(ValidationError {
390            kind: ValidationErrorKind::CronRequiresSchedule,
391            path: format!("services.{}.schedule", service_name),
392        });
393    }
394
395    Ok(())
396}
397
398// =============================================================================
399// Original validation functions (for direct use)
400// =============================================================================
401
402/// Validate that the version is "v1"
403pub fn validate_version(version: &str) -> Result<(), ValidationError> {
404    if version == "v1" {
405        Ok(())
406    } else {
407        Err(ValidationError {
408            kind: ValidationErrorKind::InvalidVersion {
409                found: version.to_string(),
410            },
411            path: "version".to_string(),
412        })
413    }
414}
415
416/// Validate a deployment name
417///
418/// Requirements:
419/// - 3-63 characters
420/// - Alphanumeric + hyphens only
421/// - Must start with alphanumeric character
422pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
423    // Check length
424    if name.len() < 3 || name.len() > 63 {
425        return Err(ValidationError {
426            kind: ValidationErrorKind::EmptyDeploymentName,
427            path: "deployment".to_string(),
428        });
429    }
430
431    // Check first character is alphanumeric
432    if let Some(first) = name.chars().next() {
433        if !first.is_ascii_alphanumeric() {
434            return Err(ValidationError {
435                kind: ValidationErrorKind::EmptyDeploymentName,
436                path: "deployment".to_string(),
437            });
438        }
439    }
440
441    // Check all characters are alphanumeric or hyphens
442    for c in name.chars() {
443        if !c.is_ascii_alphanumeric() && c != '-' {
444            return Err(ValidationError {
445                kind: ValidationErrorKind::EmptyDeploymentName,
446                path: "deployment".to_string(),
447            });
448        }
449    }
450
451    Ok(())
452}
453
454/// Validate an image name
455///
456/// Requirements:
457/// - Non-empty
458/// - Not whitespace-only
459pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
460    if name.is_empty() || name.trim().is_empty() {
461        Err(ValidationError {
462            kind: ValidationErrorKind::EmptyImageName,
463            path: "image.name".to_string(),
464        })
465    } else {
466        Ok(())
467    }
468}
469
470/// Validate CPU limit
471///
472/// Requirements:
473/// - Must be > 0
474pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
475    if *cpu > 0.0 {
476        Ok(())
477    } else {
478        Err(ValidationError {
479            kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
480            path: "resources.cpu".to_string(),
481        })
482    }
483}
484
485/// Validate memory format
486///
487/// Valid formats: number followed by Ki, Mi, Gi, or Ti suffix
488/// Examples: "512Mi", "1Gi", "2Ti", "256Ki"
489pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
490    // Valid suffixes
491    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
492
493    // Find which suffix is used, if any
494    let suffix_match = VALID_SUFFIXES
495        .iter()
496        .find(|&&suffix| value.ends_with(suffix));
497
498    match suffix_match {
499        Some(suffix) => {
500            // Extract the numeric part
501            let numeric_part = &value[..value.len() - suffix.len()];
502
503            // Check that numeric part is a valid positive number
504            match numeric_part.parse::<u64>() {
505                Ok(n) if n > 0 => Ok(()),
506                _ => Err(ValidationError {
507                    kind: ValidationErrorKind::InvalidMemoryFormat {
508                        value: value.to_string(),
509                    },
510                    path: "resources.memory".to_string(),
511                }),
512            }
513        }
514        None => Err(ValidationError {
515            kind: ValidationErrorKind::InvalidMemoryFormat {
516                value: value.to_string(),
517            },
518            path: "resources.memory".to_string(),
519        }),
520    }
521}
522
523/// Validate a port number
524///
525/// Requirements:
526/// - Must be 1-65535 (not 0)
527pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
528    if *port >= 1 {
529        Ok(())
530    } else {
531        Err(ValidationError {
532            kind: ValidationErrorKind::InvalidPort { port: *port as u32 },
533            path: "endpoints[].port".to_string(),
534        })
535    }
536}
537
538/// Validate that endpoint names are unique within a service
539pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
540    let mut seen = HashSet::new();
541
542    for endpoint in endpoints {
543        if !seen.insert(&endpoint.name) {
544            return Err(ValidationError {
545                kind: ValidationErrorKind::DuplicateEndpoint {
546                    name: endpoint.name.clone(),
547                },
548                path: "endpoints".to_string(),
549            });
550        }
551    }
552
553    Ok(())
554}
555
556/// Validate scale range
557///
558/// Requirements:
559/// - min <= max
560pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
561    if min <= max {
562        Ok(())
563    } else {
564        Err(ValidationError {
565            kind: ValidationErrorKind::InvalidScaleRange { min, max },
566            path: "scale".to_string(),
567        })
568    }
569}
570
571// =============================================================================
572// Tunnel validation functions
573// =============================================================================
574
575/// Validate tunnel TTL format (e.g., "4h", "30m", "1d")
576pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
577    humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
578        make_validation_error(
579            "invalid_tunnel_ttl",
580            format!("invalid TTL format '{}': {}", ttl, e),
581        )
582    })
583}
584
585/// Validate a TunnelAccessConfig
586pub fn validate_tunnel_access_config(
587    config: &TunnelAccessConfig,
588    path: &str,
589) -> Result<(), ValidationError> {
590    if let Some(ref max_ttl) = config.max_ttl {
591        validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
592            kind: ValidationErrorKind::InvalidTunnelTtl {
593                value: max_ttl.clone(),
594                reason: e
595                    .message
596                    .map(|m| m.to_string())
597                    .unwrap_or_else(|| "invalid duration format".to_string()),
598            },
599            path: format!("{}.access.max_ttl", path),
600        })?;
601    }
602    Ok(())
603}
604
605/// Validate an EndpointTunnelConfig
606pub fn validate_endpoint_tunnel_config(
607    config: &EndpointTunnelConfig,
608    path: &str,
609) -> Result<(), ValidationError> {
610    // remote_port: 0 means auto-assign, otherwise must be valid port
611    // Note: u16 already constrains to 0-65535, so no additional check needed
612
613    // Validate access config if present
614    if let Some(ref access) = config.access {
615        validate_tunnel_access_config(access, path)?;
616    }
617
618    Ok(())
619}
620
621/// Validate a top-level TunnelDefinition
622pub fn validate_tunnel_definition(
623    name: &str,
624    tunnel: &TunnelDefinition,
625) -> Result<(), ValidationError> {
626    let path = format!("tunnels.{}", name);
627
628    // Validate local_port (must be 1-65535, not 0)
629    if tunnel.local_port == 0 {
630        return Err(ValidationError {
631            kind: ValidationErrorKind::InvalidTunnelPort {
632                port: tunnel.local_port,
633                field: "local_port".to_string(),
634            },
635            path: format!("{}.local_port", path),
636        });
637    }
638
639    // Validate remote_port (must be 1-65535, not 0)
640    if tunnel.remote_port == 0 {
641        return Err(ValidationError {
642            kind: ValidationErrorKind::InvalidTunnelPort {
643                port: tunnel.remote_port,
644                field: "remote_port".to_string(),
645            },
646            path: format!("{}.remote_port", path),
647        });
648    }
649
650    Ok(())
651}
652
653/// Validate all tunnels in a deployment spec
654pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
655    // Validate top-level tunnels
656    for (name, tunnel) in &spec.tunnels {
657        validate_tunnel_definition(name, tunnel)?;
658    }
659
660    // Validate endpoint tunnels
661    for (service_name, service_spec) in &spec.services {
662        for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
663            if let Some(ref tunnel_config) = endpoint.tunnel {
664                let path = format!("services.{}.endpoints[{}].tunnel", service_name, idx);
665                validate_endpoint_tunnel_config(tunnel_config, &path)?;
666            }
667        }
668    }
669
670    Ok(())
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::types::{ExposeType, Protocol};
677
678    // Version validation tests
679    #[test]
680    fn test_validate_version_valid() {
681        assert!(validate_version("v1").is_ok());
682    }
683
684    #[test]
685    fn test_validate_version_invalid_v2() {
686        let result = validate_version("v2");
687        assert!(result.is_err());
688        let err = result.unwrap_err();
689        assert!(matches!(
690            err.kind,
691            ValidationErrorKind::InvalidVersion { found } if found == "v2"
692        ));
693    }
694
695    #[test]
696    fn test_validate_version_empty() {
697        let result = validate_version("");
698        assert!(result.is_err());
699        let err = result.unwrap_err();
700        assert!(matches!(
701            err.kind,
702            ValidationErrorKind::InvalidVersion { found } if found.is_empty()
703        ));
704    }
705
706    // Deployment name validation tests
707    #[test]
708    fn test_validate_deployment_name_valid() {
709        assert!(validate_deployment_name("my-app").is_ok());
710        assert!(validate_deployment_name("api").is_ok());
711        assert!(validate_deployment_name("my-service-123").is_ok());
712        assert!(validate_deployment_name("a1b").is_ok());
713    }
714
715    #[test]
716    fn test_validate_deployment_name_too_short() {
717        assert!(validate_deployment_name("ab").is_err());
718        assert!(validate_deployment_name("a").is_err());
719        assert!(validate_deployment_name("").is_err());
720    }
721
722    #[test]
723    fn test_validate_deployment_name_too_long() {
724        let long_name = "a".repeat(64);
725        assert!(validate_deployment_name(&long_name).is_err());
726    }
727
728    #[test]
729    fn test_validate_deployment_name_invalid_chars() {
730        assert!(validate_deployment_name("my_app").is_err()); // underscore
731        assert!(validate_deployment_name("my.app").is_err()); // dot
732        assert!(validate_deployment_name("my app").is_err()); // space
733        assert!(validate_deployment_name("my@app").is_err()); // special char
734    }
735
736    #[test]
737    fn test_validate_deployment_name_must_start_alphanumeric() {
738        assert!(validate_deployment_name("-myapp").is_err());
739        assert!(validate_deployment_name("_myapp").is_err());
740    }
741
742    // Image name validation tests
743    #[test]
744    fn test_validate_image_name_valid() {
745        assert!(validate_image_name("nginx:latest").is_ok());
746        assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
747        assert!(validate_image_name("ubuntu").is_ok());
748    }
749
750    #[test]
751    fn test_validate_image_name_empty() {
752        let result = validate_image_name("");
753        assert!(result.is_err());
754        assert!(matches!(
755            result.unwrap_err().kind,
756            ValidationErrorKind::EmptyImageName
757        ));
758    }
759
760    #[test]
761    fn test_validate_image_name_whitespace_only() {
762        assert!(validate_image_name("   ").is_err());
763        assert!(validate_image_name("\t\n").is_err());
764    }
765
766    // CPU validation tests
767    #[test]
768    fn test_validate_cpu_valid() {
769        assert!(validate_cpu(&0.5).is_ok());
770        assert!(validate_cpu(&1.0).is_ok());
771        assert!(validate_cpu(&2.0).is_ok());
772        assert!(validate_cpu(&0.001).is_ok());
773    }
774
775    #[test]
776    fn test_validate_cpu_zero() {
777        let result = validate_cpu(&0.0);
778        assert!(result.is_err());
779        assert!(matches!(
780            result.unwrap_err().kind,
781            ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
782        ));
783    }
784
785    #[test]
786    fn test_validate_cpu_negative() {
787        let result = validate_cpu(&-1.0);
788        assert!(result.is_err());
789        assert!(matches!(
790            result.unwrap_err().kind,
791            ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
792        ));
793    }
794
795    // Memory format validation tests
796    #[test]
797    fn test_validate_memory_format_valid() {
798        assert!(validate_memory_format("512Mi").is_ok());
799        assert!(validate_memory_format("1Gi").is_ok());
800        assert!(validate_memory_format("2Ti").is_ok());
801        assert!(validate_memory_format("256Ki").is_ok());
802        assert!(validate_memory_format("4096Mi").is_ok());
803    }
804
805    #[test]
806    fn test_validate_memory_format_invalid_suffix() {
807        assert!(validate_memory_format("512MB").is_err());
808        assert!(validate_memory_format("1GB").is_err());
809        assert!(validate_memory_format("512").is_err());
810        assert!(validate_memory_format("512m").is_err());
811    }
812
813    #[test]
814    fn test_validate_memory_format_no_number() {
815        assert!(validate_memory_format("Mi").is_err());
816        assert!(validate_memory_format("Gi").is_err());
817    }
818
819    #[test]
820    fn test_validate_memory_format_invalid_number() {
821        assert!(validate_memory_format("-512Mi").is_err());
822        assert!(validate_memory_format("0Mi").is_err());
823        assert!(validate_memory_format("abcMi").is_err());
824    }
825
826    // Port validation tests
827    #[test]
828    fn test_validate_port_valid() {
829        assert!(validate_port(&1).is_ok());
830        assert!(validate_port(&80).is_ok());
831        assert!(validate_port(&443).is_ok());
832        assert!(validate_port(&8080).is_ok());
833        assert!(validate_port(&65535).is_ok());
834    }
835
836    #[test]
837    fn test_validate_port_zero() {
838        let result = validate_port(&0);
839        assert!(result.is_err());
840        assert!(matches!(
841            result.unwrap_err().kind,
842            ValidationErrorKind::InvalidPort { port } if port == 0
843        ));
844    }
845
846    // Note: u16 cannot be negative, and max value 65535 is valid,
847    // so we only need to test 0 as the invalid case
848
849    // Unique endpoints validation tests
850    #[test]
851    fn test_validate_unique_endpoints_valid() {
852        let endpoints = vec![
853            EndpointSpec {
854                name: "http".to_string(),
855                protocol: Protocol::Http,
856                port: 8080,
857                target_port: None,
858                path: None,
859                expose: ExposeType::Public,
860                stream: None,
861                tunnel: None,
862            },
863            EndpointSpec {
864                name: "grpc".to_string(),
865                protocol: Protocol::Tcp,
866                port: 9090,
867                target_port: None,
868                path: None,
869                expose: ExposeType::Internal,
870                stream: None,
871                tunnel: None,
872            },
873        ];
874        assert!(validate_unique_endpoints(&endpoints).is_ok());
875    }
876
877    #[test]
878    fn test_validate_unique_endpoints_empty() {
879        let endpoints: Vec<EndpointSpec> = vec![];
880        assert!(validate_unique_endpoints(&endpoints).is_ok());
881    }
882
883    #[test]
884    fn test_validate_unique_endpoints_duplicates() {
885        let endpoints = vec![
886            EndpointSpec {
887                name: "http".to_string(),
888                protocol: Protocol::Http,
889                port: 8080,
890                target_port: None,
891                path: None,
892                expose: ExposeType::Public,
893                stream: None,
894                tunnel: None,
895            },
896            EndpointSpec {
897                name: "http".to_string(), // duplicate name
898                protocol: Protocol::Https,
899                port: 8443,
900                target_port: None,
901                path: None,
902                expose: ExposeType::Public,
903                stream: None,
904                tunnel: None,
905            },
906        ];
907        let result = validate_unique_endpoints(&endpoints);
908        assert!(result.is_err());
909        assert!(matches!(
910            result.unwrap_err().kind,
911            ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
912        ));
913    }
914
915    // Scale range validation tests
916    #[test]
917    fn test_validate_scale_range_valid() {
918        assert!(validate_scale_range(1, 10).is_ok());
919        assert!(validate_scale_range(1, 1).is_ok()); // min == max is valid
920        assert!(validate_scale_range(0, 5).is_ok());
921        assert!(validate_scale_range(5, 100).is_ok());
922    }
923
924    #[test]
925    fn test_validate_scale_range_min_greater_than_max() {
926        let result = validate_scale_range(10, 5);
927        assert!(result.is_err());
928        let err = result.unwrap_err();
929        assert!(matches!(
930            err.kind,
931            ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
932        ));
933    }
934
935    #[test]
936    fn test_validate_scale_range_large_gap() {
937        // Large gap between min and max should still be valid
938        assert!(validate_scale_range(1, 1000).is_ok());
939    }
940
941    // Cron schedule validation tests
942    // Note: The `cron` crate uses 7-field format: "sec min hour day-of-month month day-of-week year"
943    #[test]
944    fn test_validate_schedule_wrapper_valid() {
945        // Valid 7-field cron expressions (sec min hour dom month dow year)
946        assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); // Daily at midnight
947        assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); // Every 5 minutes
948        assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); // Weekdays at noon
949        assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); // Monthly at 2:30am on 1st
950        assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
951        // Every 10 seconds
952    }
953
954    #[test]
955    fn test_validate_schedule_wrapper_invalid() {
956        // Invalid cron expressions
957        assert!(validate_schedule_wrapper(&"".to_string()).is_err()); // Empty
958        assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); // Plain text
959        assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); // 5-field (standard unix cron) not supported
960        assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
961        // Invalid second (60)
962    }
963
964    // Secret reference validation tests
965    #[test]
966    fn test_validate_secret_reference_plain_values() {
967        // Plain values should pass (not secret refs)
968        assert!(validate_secret_reference("my-value").is_ok());
969        assert!(validate_secret_reference("").is_ok());
970        assert!(validate_secret_reference("some string").is_ok());
971        assert!(validate_secret_reference("$E:MY_VAR").is_ok()); // Host env ref, not secret
972    }
973
974    #[test]
975    fn test_validate_secret_reference_valid() {
976        // Valid secret references
977        assert!(validate_secret_reference("$S:my-secret").is_ok());
978        assert!(validate_secret_reference("$S:api_key").is_ok());
979        assert!(validate_secret_reference("$S:MySecret123").is_ok());
980        assert!(validate_secret_reference("$S:a").is_ok()); // Single letter is valid
981    }
982
983    #[test]
984    fn test_validate_secret_reference_cross_service() {
985        // Valid cross-service references
986        assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
987        assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
988        assert!(validate_secret_reference("$S:@svc/secret").is_ok());
989    }
990
991    #[test]
992    fn test_validate_secret_reference_empty_after_prefix() {
993        // Empty after $S:
994        assert!(validate_secret_reference("$S:").is_err());
995    }
996
997    #[test]
998    fn test_validate_secret_reference_must_start_with_letter() {
999        // Secret name must start with letter
1000        assert!(validate_secret_reference("$S:123-secret").is_err());
1001        assert!(validate_secret_reference("$S:-my-secret").is_err());
1002        assert!(validate_secret_reference("$S:_underscore").is_err());
1003    }
1004
1005    #[test]
1006    fn test_validate_secret_reference_invalid_chars() {
1007        // Invalid characters in secret name
1008        assert!(validate_secret_reference("$S:my.secret").is_err());
1009        assert!(validate_secret_reference("$S:my secret").is_err());
1010        assert!(validate_secret_reference("$S:my@secret").is_err());
1011    }
1012
1013    #[test]
1014    fn test_validate_secret_reference_cross_service_invalid() {
1015        // Missing slash in cross-service ref
1016        assert!(validate_secret_reference("$S:@service").is_err());
1017        // Empty service name
1018        assert!(validate_secret_reference("$S:@/secret").is_err());
1019        // Empty secret name
1020        assert!(validate_secret_reference("$S:@service/").is_err());
1021        // Service name must start with letter
1022        assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1023    }
1024
1025    // =========================================================================
1026    // Tunnel validation tests
1027    // =========================================================================
1028
1029    #[test]
1030    fn test_validate_tunnel_ttl_valid() {
1031        assert!(validate_tunnel_ttl("30m").is_ok());
1032        assert!(validate_tunnel_ttl("4h").is_ok());
1033        assert!(validate_tunnel_ttl("1d").is_ok());
1034        assert!(validate_tunnel_ttl("1h 30m").is_ok());
1035        assert!(validate_tunnel_ttl("2h30m").is_ok());
1036    }
1037
1038    #[test]
1039    fn test_validate_tunnel_ttl_invalid() {
1040        assert!(validate_tunnel_ttl("").is_err());
1041        assert!(validate_tunnel_ttl("invalid").is_err());
1042        assert!(validate_tunnel_ttl("30").is_err()); // Missing unit
1043        assert!(validate_tunnel_ttl("-1h").is_err()); // Negative
1044    }
1045
1046    #[test]
1047    fn test_validate_tunnel_definition_valid() {
1048        let tunnel = TunnelDefinition {
1049            from: "node-a".to_string(),
1050            to: "node-b".to_string(),
1051            local_port: 8080,
1052            remote_port: 9000,
1053            protocol: crate::types::TunnelProtocol::Tcp,
1054            expose: ExposeType::Internal,
1055        };
1056        assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
1057    }
1058
1059    #[test]
1060    fn test_validate_tunnel_definition_local_port_zero() {
1061        let tunnel = TunnelDefinition {
1062            from: "node-a".to_string(),
1063            to: "node-b".to_string(),
1064            local_port: 0,
1065            remote_port: 9000,
1066            protocol: crate::types::TunnelProtocol::Tcp,
1067            expose: ExposeType::Internal,
1068        };
1069        let result = validate_tunnel_definition("test-tunnel", &tunnel);
1070        assert!(result.is_err());
1071        assert!(matches!(
1072            result.unwrap_err().kind,
1073            ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
1074        ));
1075    }
1076
1077    #[test]
1078    fn test_validate_tunnel_definition_remote_port_zero() {
1079        let tunnel = TunnelDefinition {
1080            from: "node-a".to_string(),
1081            to: "node-b".to_string(),
1082            local_port: 8080,
1083            remote_port: 0,
1084            protocol: crate::types::TunnelProtocol::Tcp,
1085            expose: ExposeType::Internal,
1086        };
1087        let result = validate_tunnel_definition("test-tunnel", &tunnel);
1088        assert!(result.is_err());
1089        assert!(matches!(
1090            result.unwrap_err().kind,
1091            ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
1092        ));
1093    }
1094
1095    #[test]
1096    fn test_validate_endpoint_tunnel_config_valid() {
1097        let config = EndpointTunnelConfig {
1098            enabled: true,
1099            from: Some("node-1".to_string()),
1100            to: Some("ingress".to_string()),
1101            remote_port: 8080,
1102            expose: Some(ExposeType::Public),
1103            access: None,
1104        };
1105        assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1106    }
1107
1108    #[test]
1109    fn test_validate_endpoint_tunnel_config_with_access() {
1110        let config = EndpointTunnelConfig {
1111            enabled: true,
1112            from: None,
1113            to: None,
1114            remote_port: 0, // auto-assign
1115            expose: None,
1116            access: Some(TunnelAccessConfig {
1117                enabled: true,
1118                max_ttl: Some("4h".to_string()),
1119                audit: true,
1120            }),
1121        };
1122        assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1123    }
1124
1125    #[test]
1126    fn test_validate_endpoint_tunnel_config_invalid_ttl() {
1127        let config = EndpointTunnelConfig {
1128            enabled: true,
1129            from: None,
1130            to: None,
1131            remote_port: 0,
1132            expose: None,
1133            access: Some(TunnelAccessConfig {
1134                enabled: true,
1135                max_ttl: Some("invalid".to_string()),
1136                audit: false,
1137            }),
1138        };
1139        let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
1140        assert!(result.is_err());
1141        assert!(matches!(
1142            result.unwrap_err().kind,
1143            ValidationErrorKind::InvalidTunnelTtl { .. }
1144        ));
1145    }
1146}