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