Skip to main content

zlayer_types/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::spec::error::{ValidationError, ValidationErrorKind};
6use crate::spec::types::{
7    DeploymentSpec, EndpointSpec, EndpointTunnelConfig, Protocol, ResourceType, ScaleSpec,
8    ServiceSpec, ServiceType, SwarmRole, TunnelAccessConfig, TunnelDefinition, VerticalMode,
9    VerticalScaleSpec,
10};
11use cron::Schedule;
12use std::collections::HashSet;
13use std::str::FromStr;
14
15// =============================================================================
16// Validator crate wrapper functions
17// =============================================================================
18// These functions match the signature expected by #[validate(custom(function = "..."))]
19// They return Result<(), validator::ValidationError>
20
21fn make_validation_error(
22    code: &'static str,
23    message: impl Into<std::borrow::Cow<'static, str>>,
24) -> validator::ValidationError {
25    let mut err = validator::ValidationError::new(code);
26    err.message = Some(message.into());
27    err
28}
29
30/// Wrapper for `validate_version` for use with validator crate
31///
32/// # Errors
33///
34/// Returns a validation error if the version is not "v1".
35pub fn validate_version_wrapper(version: &str) -> Result<(), validator::ValidationError> {
36    if version == "v1" {
37        Ok(())
38    } else {
39        Err(make_validation_error(
40            "invalid_version",
41            format!("version must be 'v1', found '{version}'"),
42        ))
43    }
44}
45
46/// Wrapper for `validate_deployment_name` for use with validator crate
47///
48/// # Errors
49///
50/// Returns a validation error if the name is invalid.
51pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
52    // Check length
53    if name.len() < 3 || name.len() > 63 {
54        return Err(make_validation_error(
55            "invalid_deployment_name",
56            "deployment name must be 3-63 characters",
57        ));
58    }
59
60    // Check first character is alphanumeric
61    if let Some(first) = name.chars().next() {
62        if !first.is_ascii_alphanumeric() {
63            return Err(make_validation_error(
64                "invalid_deployment_name",
65                "deployment name must start with alphanumeric character",
66            ));
67        }
68    }
69
70    // Check all characters are alphanumeric or hyphens
71    for c in name.chars() {
72        if !c.is_ascii_alphanumeric() && c != '-' {
73            return Err(make_validation_error(
74                "invalid_deployment_name",
75                "deployment name can only contain alphanumeric characters and hyphens",
76            ));
77        }
78    }
79
80    Ok(())
81}
82
83/// Wrapper for `validate_cpu` for use with validator crate
84/// Note: For `Option<f64>` fields, validator crate unwraps and passes the inner `f64`
85///
86/// # Errors
87///
88/// Returns a validation error if CPU is <= 0.
89pub fn validate_cpu_option_wrapper(cpu: f64) -> Result<(), validator::ValidationError> {
90    if cpu <= 0.0 {
91        Err(make_validation_error(
92            "invalid_cpu",
93            format!("CPU limit must be > 0, found {cpu}"),
94        ))
95    } else {
96        Ok(())
97    }
98}
99
100/// Wrapper for `validate_memory_format` for use with validator crate
101/// Note: For `Option<String>` fields, validator crate unwraps and passes `&String`
102///
103/// # Errors
104///
105/// Returns a validation error if the memory format is invalid.
106pub fn validate_memory_option_wrapper(value: &String) -> Result<(), validator::ValidationError> {
107    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
108
109    let suffix_match = VALID_SUFFIXES
110        .iter()
111        .find(|&&suffix| value.ends_with(suffix));
112
113    match suffix_match {
114        Some(suffix) => {
115            let numeric_part = &value[..value.len() - suffix.len()];
116            match numeric_part.parse::<u64>() {
117                Ok(n) if n > 0 => Ok(()),
118                _ => Err(make_validation_error(
119                    "invalid_memory_format",
120                    format!("invalid memory format: '{value}'"),
121                )),
122            }
123        }
124        None => Err(make_validation_error(
125            "invalid_memory_format",
126            format!("invalid memory format: '{value}' (use Ki, Mi, Gi, or Ti suffix)"),
127        )),
128    }
129}
130
131/// Convert a memory quantity string (`"512Mi"`, `"1Gi"`, `"2Ti"`, `"256Ki"`)
132/// into a byte count. Suffixes are binary (Ki = 1024, Mi = 1024², …).
133///
134/// Returns `None` if the value is malformed, lacks a recognized suffix, or
135/// overflows `u64`. The accepted grammar matches
136/// [`validate_memory_option_wrapper`]: a positive integer followed by one of
137/// `Ki` / `Mi` / `Gi` / `Ti`.
138#[must_use]
139pub fn memory_string_to_bytes(value: &str) -> Option<u64> {
140    const SUFFIXES: [(&str, u64); 4] = [
141        ("Ki", 1024),
142        ("Mi", 1024 * 1024),
143        ("Gi", 1024 * 1024 * 1024),
144        ("Ti", 1024 * 1024 * 1024 * 1024),
145    ];
146    for (suffix, mult) in SUFFIXES {
147        if let Some(numeric) = value.strip_suffix(suffix) {
148            let n = numeric.parse::<u64>().ok()?;
149            return n.checked_mul(mult);
150        }
151    }
152    None
153}
154
155/// Wrapper for `validate_port` for use with validator crate
156/// Note: validator crate passes primitive types by value for custom validators
157///
158/// # Errors
159///
160/// Returns a validation error if the port is 0.
161pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
162    if port >= 1 {
163        Ok(())
164    } else {
165        Err(make_validation_error(
166            "invalid_port",
167            "port must be between 1-65535",
168        ))
169    }
170}
171
172/// Validate scale range (min <= max) for `ScaleSpec`
173///
174/// # Errors
175///
176/// Returns a validation error if min > max.
177pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
178    if let ScaleSpec::Adaptive {
179        min,
180        max,
181        targets,
182        vertical,
183        ..
184    } = scale
185    {
186        if *min > *max {
187            return Err(make_validation_error(
188                "invalid_scale_range",
189                format!("scale min ({min}) cannot be greater than max ({max})"),
190            ));
191        }
192
193        // HPA/VPA conflict guard: a service may not autoscale horizontally and
194        // vertically on the SAME resource metric in `Auto` mode — the two
195        // controllers fight (horizontal changes replica count off a utilization
196        // % that vertical is simultaneously moving). Mirrors Kubernetes guidance.
197        if let Some(VerticalScaleSpec {
198            mode: VerticalMode::Auto,
199            ..
200        }) = vertical
201        {
202            if targets.cpu.is_some() {
203                return Err(make_validation_error(
204                    "hpa_vpa_conflict",
205                    "cannot horizontally scale on CPU while vertical mode is `auto` \
206                     (set vertical to `recommend`, or drop the CPU target)"
207                        .to_string(),
208                ));
209            }
210            if targets.memory.is_some() {
211                return Err(make_validation_error(
212                    "hpa_vpa_conflict",
213                    "cannot horizontally scale on memory while vertical mode is `auto` \
214                     (set vertical to `recommend`, or drop the memory target)"
215                        .to_string(),
216                ));
217            }
218        }
219    }
220    Ok(())
221}
222
223/// Wrapper for `validate_cron_schedule` for use with validator crate
224/// Note: For `Option<String>` fields, validator crate unwraps and passes `&String`
225///
226/// # Errors
227///
228/// Returns a validation error if the cron expression is invalid.
229pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
230    Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
231        make_validation_error(
232            "invalid_cron_schedule",
233            format!("invalid cron schedule '{schedule}': {e}"),
234        )
235    })
236}
237
238/// Validate a secret reference name format
239///
240/// Secret names must:
241/// - Start with a letter (a-z, A-Z)
242/// - Contain only alphanumeric characters, hyphens, and underscores
243/// - Optionally be prefixed with `@service/` for cross-service references
244///
245/// Examples of valid secret refs:
246/// - `$S:my-secret`
247/// - `$S:api_key`
248/// - `$S:@auth-service/jwt-secret`
249///
250/// # Errors
251///
252/// Returns a validation error if the secret reference format is invalid.
253///
254/// # Panics
255///
256/// Panics if the secret name is empty after validation (unreachable in practice).
257pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
258    // Only validate values that start with $S:
259    if !value.starts_with("$S:") {
260        return Ok(());
261    }
262
263    let secret_ref = &value[3..]; // Remove "$S:" prefix
264
265    if secret_ref.is_empty() {
266        return Err(make_validation_error(
267            "invalid_secret_reference",
268            "secret reference cannot be empty after $S:",
269        ));
270    }
271
272    // Check for cross-service reference format: @service/secret-name
273    let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
274        // Cross-service reference: @service/secret-name
275        let parts: Vec<&str> = rest.splitn(2, '/').collect();
276        if parts.len() != 2 {
277            return Err(make_validation_error(
278                "invalid_secret_reference",
279                format!(
280                    "cross-service secret reference '{value}' must have format @service/secret-name"
281                ),
282            ));
283        }
284
285        let service_name = parts[0];
286        let secret_name = parts[1];
287
288        // Validate service name part
289        if service_name.is_empty() {
290            return Err(make_validation_error(
291                "invalid_secret_reference",
292                format!("service name in secret reference '{value}' cannot be empty"),
293            ));
294        }
295
296        if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
297            return Err(make_validation_error(
298                "invalid_secret_reference",
299                format!("service name in secret reference '{value}' must start with a letter"),
300            ));
301        }
302
303        for c in service_name.chars() {
304            if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
305                return Err(make_validation_error(
306                    "invalid_secret_reference",
307                    format!(
308                        "service name in secret reference '{value}' contains invalid character '{c}'"
309                    ),
310                ));
311            }
312        }
313
314        secret_name
315    } else {
316        secret_ref
317    };
318
319    // Validate the secret name
320    if secret_name.is_empty() {
321        return Err(make_validation_error(
322            "invalid_secret_reference",
323            format!("secret name in '{value}' cannot be empty"),
324        ));
325    }
326
327    // Must start with a letter
328    let first_char = secret_name.chars().next().unwrap();
329    if !first_char.is_ascii_alphabetic() {
330        return Err(make_validation_error(
331            "invalid_secret_reference",
332            format!("secret name in '{value}' must start with a letter, found '{first_char}'"),
333        ));
334    }
335
336    // All characters must be alphanumeric, hyphen, or underscore
337    for c in secret_name.chars() {
338        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
339            return Err(make_validation_error(
340                "invalid_secret_reference",
341                format!(
342                    "secret name in '{value}' contains invalid character '{c}' (only alphanumeric, hyphens, underscores allowed)"
343                ),
344            ));
345        }
346    }
347
348    Ok(())
349}
350
351/// Validate all environment variable values in a service spec
352///
353/// # Errors
354///
355/// Returns a validation error if any env var has an invalid secret reference.
356#[allow(clippy::implicit_hasher)]
357pub fn validate_env_vars(
358    service_name: &str,
359    env: &std::collections::HashMap<String, String>,
360) -> Result<(), crate::spec::error::ValidationError> {
361    for (key, value) in env {
362        if let Err(e) = validate_secret_reference(value) {
363            return Err(crate::spec::error::ValidationError {
364                kind: crate::spec::error::ValidationErrorKind::InvalidEnvVar {
365                    key: key.clone(),
366                    reason: e
367                        .message
368                        .map_or_else(|| "invalid secret reference".to_string(), |m| m.to_string()),
369                },
370                path: format!("services.{service_name}.env.{key}"),
371            });
372        }
373    }
374    Ok(())
375}
376
377/// Validate storage name format (lowercase alphanumeric with hyphens)
378///
379/// # Errors
380///
381/// Returns a validation error if the storage name format is invalid.
382///
383/// # Panics
384///
385/// Panics if the regex pattern is invalid (should never happen with a static pattern).
386pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
387    // Must be lowercase alphanumeric with hyphens, not starting/ending with hyphen
388    let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
389    if !re.is_match(name) || name.len() > 63 {
390        return Err(make_validation_error(
391            "invalid_storage_name",
392            format!("storage name '{name}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen"),
393        ));
394    }
395    Ok(())
396}
397
398/// Wrapper for `validate_storage_name` for use with validator crate
399///
400/// # Errors
401///
402/// Returns a validation error if the storage name format is invalid.
403pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
404    validate_storage_name(name)
405}
406
407// =============================================================================
408// Cross-field validation functions (called from lib.rs)
409// =============================================================================
410
411/// Validate that all dependency service references exist
412///
413/// # Errors
414///
415/// Returns a validation error if a dependency references an unknown service.
416pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
417    let service_names: HashSet<&str> = spec
418        .services
419        .keys()
420        .map(std::string::String::as_str)
421        .collect();
422
423    for (service_name, service_spec) in &spec.services {
424        for dep in &service_spec.depends {
425            if !service_names.contains(dep.service.as_str()) {
426                return Err(ValidationError {
427                    kind: ValidationErrorKind::UnknownDependency {
428                        service: dep.service.clone(),
429                    },
430                    path: format!("services.{service_name}.depends"),
431                });
432            }
433        }
434    }
435
436    Ok(())
437}
438
439/// Validate pipeline-parallel inference swarm invariants.
440///
441/// Services that declare `resources.gpu.sharding` are grouped by `swarm_id`.
442/// For each swarm group this checks:
443/// 1. Every `peers[].service` reference resolves to a known service.
444/// 2. There is exactly one member with `role: coordinator`.
445/// 3. All members agree on `layer_count`.
446/// 4. The `stage` members form a contiguous `[0, layer_count)` cover with no
447///    gaps and no overlaps.
448///
449/// # Errors
450///
451/// Returns a validation error if any of the above invariants is violated.
452#[allow(clippy::too_many_lines)]
453pub fn validate_swarm(spec: &DeploymentSpec) -> Result<(), ValidationError> {
454    let service_names: HashSet<&str> = spec
455        .services
456        .keys()
457        .map(std::string::String::as_str)
458        .collect();
459
460    // Group swarm members by swarm_id, preserving the declaring service name.
461    let mut swarms: std::collections::BTreeMap<&str, Vec<(&str, &super::types::ShardingSpec)>> =
462        std::collections::BTreeMap::new();
463
464    for (service_name, service_spec) in &spec.services {
465        let Some(sharding) = service_spec
466            .resources
467            .gpu
468            .as_ref()
469            .and_then(|g| g.sharding.as_ref())
470        else {
471            continue;
472        };
473
474        // 1. Peer references must resolve to known services.
475        for peer in &sharding.peers {
476            if !service_names.contains(peer.service.as_str()) {
477                return Err(ValidationError {
478                    kind: ValidationErrorKind::UnknownDependency {
479                        service: peer.service.clone(),
480                    },
481                    path: format!("services.{service_name}.resources.gpu.sharding.peers"),
482                });
483            }
484        }
485
486        swarms
487            .entry(sharding.swarm_id.as_str())
488            .or_default()
489            .push((service_name.as_str(), sharding));
490    }
491
492    for (swarm_id, members) in &swarms {
493        // 2. Exactly one coordinator per swarm.
494        let coordinators: Vec<&str> = members
495            .iter()
496            .filter(|(_, s)| s.role == SwarmRole::Coordinator)
497            .map(|(name, _)| *name)
498            .collect();
499        if coordinators.len() != 1 {
500            let first_member = members.first().map_or("", |(name, _)| *name);
501            return Err(ValidationError {
502                kind: ValidationErrorKind::Generic {
503                    message: format!(
504                        "swarm '{swarm_id}' must have exactly one coordinator, found {} ({})",
505                        coordinators.len(),
506                        if coordinators.is_empty() {
507                            "none".to_string()
508                        } else {
509                            coordinators.join(", ")
510                        }
511                    ),
512                },
513                path: format!("services.{first_member}.resources.gpu.sharding"),
514            });
515        }
516
517        // 3. All members must agree on layer_count.
518        let expected_count = members[0].1.layer_count;
519        for (member_name, sharding) in members {
520            if sharding.layer_count != expected_count {
521                return Err(ValidationError {
522                    kind: ValidationErrorKind::Generic {
523                        message: format!(
524                            "swarm '{swarm_id}' has inconsistent layer_count: service '{member_name}' declares {} but the swarm expects {expected_count}",
525                            sharding.layer_count
526                        ),
527                    },
528                    path: format!("services.{member_name}.resources.gpu.sharding.layer_count"),
529                });
530            }
531        }
532
533        // 4. Stage members must contiguously cover [0, layer_count).
534        let mut blocks: Vec<(u32, u32, &str)> = members
535            .iter()
536            .filter(|(_, s)| s.role == SwarmRole::Stage)
537            .map(|(name, s)| (s.layer_start, s.layer_end, *name))
538            .collect();
539        blocks.sort_by_key(|(start, _, _)| *start);
540
541        if blocks.is_empty() {
542            return Err(ValidationError {
543                kind: ValidationErrorKind::Generic {
544                    message: format!(
545                        "swarm '{swarm_id}' has no stage members; at least one stage is required to cover layers [0, {expected_count})"
546                    ),
547                },
548                path: format!("services.{coordinator}.resources.gpu.sharding", coordinator = coordinators[0]),
549            });
550        }
551
552        let first = blocks[0];
553        if first.0 != 0 {
554            return Err(ValidationError {
555                kind: ValidationErrorKind::Generic {
556                    message: format!(
557                        "swarm '{swarm_id}' coverage does not start at layer 0: service '{}' starts at {}",
558                        first.2, first.0
559                    ),
560                },
561                path: format!("services.{}.resources.gpu.sharding.layer_start", first.2),
562            });
563        }
564
565        let mut prev_end = first.1;
566        for (start, end, member_name) in blocks.iter().skip(1) {
567            if *start < prev_end {
568                return Err(ValidationError {
569                    kind: ValidationErrorKind::Generic {
570                        message: format!(
571                            "swarm '{swarm_id}' has overlapping coverage: service '{member_name}' starts at layer {start} but the previous stage already covers up to layer {prev_end}"
572                        ),
573                    },
574                    path: format!("services.{member_name}.resources.gpu.sharding.layer_start"),
575                });
576            }
577            if *start > prev_end {
578                return Err(ValidationError {
579                    kind: ValidationErrorKind::Generic {
580                        message: format!(
581                            "swarm '{swarm_id}' has a coverage gap: layers [{prev_end}, {start}) are unassigned before service '{member_name}'"
582                        ),
583                    },
584                    path: format!("services.{member_name}.resources.gpu.sharding.layer_start"),
585                });
586            }
587            prev_end = *end;
588        }
589
590        if prev_end != expected_count {
591            let last_member = blocks.last().map_or("", |(_, _, name)| *name);
592            return Err(ValidationError {
593                kind: ValidationErrorKind::Generic {
594                    message: format!(
595                        "swarm '{swarm_id}' coverage ends at layer {prev_end} but layer_count is {expected_count}: layers [{prev_end}, {expected_count}) are unassigned after service '{last_member}'"
596                    ),
597                },
598                path: format!("services.{last_member}.resources.gpu.sharding.layer_end"),
599            });
600        }
601    }
602
603    Ok(())
604}
605
606/// Validate that each service has unique endpoint names
607///
608/// # Errors
609///
610/// Returns a validation error if any service has duplicate endpoint names.
611pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
612    for (service_name, service_spec) in &spec.services {
613        let mut seen = HashSet::new();
614        for endpoint in &service_spec.endpoints {
615            if !seen.insert(&endpoint.name) {
616                return Err(ValidationError {
617                    kind: ValidationErrorKind::DuplicateEndpoint {
618                        name: endpoint.name.clone(),
619                    },
620                    path: format!("services.{service_name}.endpoints"),
621                });
622            }
623        }
624    }
625
626    Ok(())
627}
628
629/// Validate schedule/rtype consistency for all services
630///
631/// # Errors
632///
633/// Returns a validation error if schedule/rtype are inconsistent.
634pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
635    for (service_name, service_spec) in &spec.services {
636        validate_service_schedule(service_name, service_spec)?;
637    }
638    Ok(())
639}
640
641/// Validate schedule/rtype consistency for a single service
642///
643/// # Errors
644///
645/// Returns a validation error if a non-cron service has a schedule, or vice versa.
646pub fn validate_service_schedule(
647    service_name: &str,
648    spec: &ServiceSpec,
649) -> Result<(), ValidationError> {
650    // If schedule is set, rtype must be Cron
651    if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
652        return Err(ValidationError {
653            kind: ValidationErrorKind::ScheduleOnlyForCron,
654            path: format!("services.{service_name}.schedule"),
655        });
656    }
657
658    // If rtype is Cron, schedule must be set
659    if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
660        return Err(ValidationError {
661            kind: ValidationErrorKind::CronRequiresSchedule,
662            path: format!("services.{service_name}.schedule"),
663        });
664    }
665
666    Ok(())
667}
668
669// =============================================================================
670// Original validation functions (for direct use)
671// =============================================================================
672
673/// Validate that the version is "v1"
674///
675/// # Errors
676///
677/// Returns a validation error if the version is not "v1".
678pub fn validate_version(version: &str) -> Result<(), ValidationError> {
679    if version == "v1" {
680        Ok(())
681    } else {
682        Err(ValidationError {
683            kind: ValidationErrorKind::InvalidVersion {
684                found: version.to_string(),
685            },
686            path: "version".to_string(),
687        })
688    }
689}
690
691/// Validate a deployment name
692///
693/// Requirements:
694/// - 3-63 characters
695/// - Alphanumeric + hyphens only
696/// - Must start with alphanumeric character
697///
698/// # Errors
699///
700/// Returns a validation error if the deployment name is invalid.
701pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
702    // Check length
703    if name.len() < 3 || name.len() > 63 {
704        return Err(ValidationError {
705            kind: ValidationErrorKind::EmptyDeploymentName,
706            path: "deployment".to_string(),
707        });
708    }
709
710    // Check first character is alphanumeric
711    if let Some(first) = name.chars().next() {
712        if !first.is_ascii_alphanumeric() {
713            return Err(ValidationError {
714                kind: ValidationErrorKind::EmptyDeploymentName,
715                path: "deployment".to_string(),
716            });
717        }
718    }
719
720    // Check all characters are alphanumeric or hyphens
721    for c in name.chars() {
722        if !c.is_ascii_alphanumeric() && c != '-' {
723            return Err(ValidationError {
724                kind: ValidationErrorKind::EmptyDeploymentName,
725                path: "deployment".to_string(),
726            });
727        }
728    }
729
730    Ok(())
731}
732
733/// Validate an image name
734///
735/// Requirements:
736/// - Non-empty
737/// - Not whitespace-only
738///
739/// # Errors
740///
741/// Returns a validation error if the image name is empty or whitespace-only.
742pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
743    if name.is_empty() || name.trim().is_empty() {
744        Err(ValidationError {
745            kind: ValidationErrorKind::EmptyImageName,
746            path: "image.name".to_string(),
747        })
748    } else {
749        Ok(())
750    }
751}
752
753/// Validate CPU limit
754///
755/// Requirements:
756/// - Must be > 0
757///
758/// # Errors
759///
760/// Returns a validation error if CPU is <= 0.
761pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
762    if *cpu > 0.0 {
763        Ok(())
764    } else {
765        Err(ValidationError {
766            kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
767            path: "resources.cpu".to_string(),
768        })
769    }
770}
771
772/// Validate memory format
773///
774/// Valid formats: number followed by Ki, Mi, Gi, or Ti suffix
775/// Examples: "512Mi", "1Gi", "2Ti", "256Ki"
776///
777/// # Errors
778///
779/// Returns a validation error if the memory format is invalid.
780pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
781    // Valid suffixes
782    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
783
784    // Find which suffix is used, if any
785    let suffix_match = VALID_SUFFIXES
786        .iter()
787        .find(|&&suffix| value.ends_with(suffix));
788
789    match suffix_match {
790        Some(suffix) => {
791            // Extract the numeric part
792            let numeric_part = &value[..value.len() - suffix.len()];
793
794            // Check that numeric part is a valid positive number
795            match numeric_part.parse::<u64>() {
796                Ok(n) if n > 0 => Ok(()),
797                _ => Err(ValidationError {
798                    kind: ValidationErrorKind::InvalidMemoryFormat {
799                        value: value.to_string(),
800                    },
801                    path: "resources.memory".to_string(),
802                }),
803            }
804        }
805        None => Err(ValidationError {
806            kind: ValidationErrorKind::InvalidMemoryFormat {
807                value: value.to_string(),
808            },
809            path: "resources.memory".to_string(),
810        }),
811    }
812}
813
814/// Validate a port number
815///
816/// Requirements:
817/// - Must be 1-65535 (not 0)
818///
819/// # Errors
820///
821/// Returns a validation error if the port is 0.
822pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
823    if *port >= 1 {
824        Ok(())
825    } else {
826        Err(ValidationError {
827            kind: ValidationErrorKind::InvalidPort {
828                port: u32::from(*port),
829            },
830            path: "endpoints[].port".to_string(),
831        })
832    }
833}
834
835/// Validate that endpoint names are unique within a service
836///
837/// # Errors
838///
839/// Returns a validation error if duplicate endpoint names are found.
840pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
841    let mut seen = HashSet::new();
842
843    for endpoint in endpoints {
844        if !seen.insert(&endpoint.name) {
845            return Err(ValidationError {
846                kind: ValidationErrorKind::DuplicateEndpoint {
847                    name: endpoint.name.clone(),
848                },
849                path: "endpoints".to_string(),
850            });
851        }
852    }
853
854    Ok(())
855}
856
857/// Validate scale range
858///
859/// Requirements:
860/// - min <= max
861///
862/// # Errors
863///
864/// Returns a validation error if min > max.
865pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
866    if min <= max {
867        Ok(())
868    } else {
869        Err(ValidationError {
870            kind: ValidationErrorKind::InvalidScaleRange { min, max },
871            path: "scale".to_string(),
872        })
873    }
874}
875
876// =============================================================================
877// Tunnel validation functions
878// =============================================================================
879
880/// Validate tunnel TTL format (e.g., "4h", "30m", "1d")
881///
882/// # Errors
883///
884/// Returns a validation error if the TTL format is invalid.
885pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
886    humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
887        make_validation_error(
888            "invalid_tunnel_ttl",
889            format!("invalid TTL format '{ttl}': {e}"),
890        )
891    })
892}
893
894/// Validate a `TunnelAccessConfig`
895///
896/// # Errors
897///
898/// Returns a validation error if the TTL format is invalid.
899pub fn validate_tunnel_access_config(
900    config: &TunnelAccessConfig,
901    path: &str,
902) -> Result<(), ValidationError> {
903    if let Some(ref max_ttl) = config.max_ttl {
904        validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
905            kind: ValidationErrorKind::InvalidTunnelTtl {
906                value: max_ttl.clone(),
907                reason: e
908                    .message
909                    .map_or_else(|| "invalid duration format".to_string(), |m| m.to_string()),
910            },
911            path: format!("{path}.access.max_ttl"),
912        })?;
913    }
914    Ok(())
915}
916
917/// Validate an `EndpointTunnelConfig`
918///
919/// # Errors
920///
921/// Returns a validation error if the access config has an invalid TTL.
922pub fn validate_endpoint_tunnel_config(
923    config: &EndpointTunnelConfig,
924    path: &str,
925) -> Result<(), ValidationError> {
926    // remote_port: 0 means auto-assign, otherwise must be valid port
927    // Note: u16 already constrains to 0-65535, so no additional check needed
928
929    // Validate access config if present
930    if let Some(ref access) = config.access {
931        validate_tunnel_access_config(access, path)?;
932    }
933
934    Ok(())
935}
936
937/// Validate a top-level `TunnelDefinition`
938///
939/// # Errors
940///
941/// Returns a validation error if any port is 0.
942pub fn validate_tunnel_definition(
943    name: &str,
944    tunnel: &TunnelDefinition,
945) -> Result<(), ValidationError> {
946    let path = format!("tunnels.{name}");
947
948    // Validate local_port (must be 1-65535, not 0)
949    if tunnel.local_port == 0 {
950        return Err(ValidationError {
951            kind: ValidationErrorKind::InvalidTunnelPort {
952                port: tunnel.local_port,
953                field: "local_port".to_string(),
954            },
955            path: format!("{path}.local_port"),
956        });
957    }
958
959    // Validate remote_port (must be 1-65535, not 0)
960    if tunnel.remote_port == 0 {
961        return Err(ValidationError {
962            kind: ValidationErrorKind::InvalidTunnelPort {
963                port: tunnel.remote_port,
964                field: "remote_port".to_string(),
965            },
966            path: format!("{path}.remote_port"),
967        });
968    }
969
970    Ok(())
971}
972
973/// Validate all tunnels in a deployment spec
974///
975/// # Errors
976///
977/// Returns a validation error if any tunnel configuration is invalid.
978pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
979    // Validate top-level tunnels
980    for (name, tunnel) in &spec.tunnels {
981        validate_tunnel_definition(name, tunnel)?;
982    }
983
984    // Validate endpoint tunnels
985    for (service_name, service_spec) in &spec.services {
986        for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
987            if let Some(ref tunnel_config) = endpoint.tunnel {
988                let path = format!("services.{service_name}.endpoints[{idx}].tunnel");
989                validate_endpoint_tunnel_config(tunnel_config, &path)?;
990            }
991        }
992    }
993
994    Ok(())
995}
996
997// =============================================================================
998// WASM validation functions
999// =============================================================================
1000
1001/// Validate WASM configuration for all services in a deployment spec
1002///
1003/// # Errors
1004///
1005/// Returns a validation error if any WASM configuration is invalid.
1006pub fn validate_wasm_configs(spec: &DeploymentSpec) -> Result<(), ValidationError> {
1007    for (service_name, service_spec) in &spec.services {
1008        validate_wasm_config(service_name, service_spec)?;
1009    }
1010    Ok(())
1011}
1012
1013/// Validate WASM configuration for a single service
1014///
1015/// Checks:
1016/// - WASM config should not be present on non-WASM service types
1017/// - `min_instances` <= `max_instances`
1018/// - `max_memory` format validation
1019/// - Capability restriction validation (users can only restrict from defaults, not grant)
1020/// - `WasmHttp` must have at least one HTTP endpoint (if endpoints exist)
1021/// - Preopens must have non-empty source and target
1022///
1023/// # Errors
1024///
1025/// Returns a validation error if the WASM configuration is invalid.
1026pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
1027    // If service_type is NOT wasm but wasm config IS present, that's an error
1028    if !spec.service_type.is_wasm() && spec.wasm.is_some() {
1029        return Err(ValidationError {
1030            kind: ValidationErrorKind::WasmConfigOnNonWasmType,
1031            path: format!("services.{service_name}.wasm"),
1032        });
1033    }
1034
1035    if let Some(ref wasm) = spec.wasm {
1036        validate_wasm_fields(service_name, wasm)?;
1037        validate_wasm_capabilities(service_name, spec, wasm)?;
1038        validate_wasm_http_endpoints(service_name, spec)?;
1039        validate_wasm_preopens(service_name, wasm)?;
1040    }
1041
1042    Ok(())
1043}
1044
1045/// Validate basic WASM config fields (memory format, instance range).
1046fn validate_wasm_fields(
1047    service_name: &str,
1048    wasm: &crate::spec::types::WasmConfig,
1049) -> Result<(), ValidationError> {
1050    if let Some(ref max_mem) = wasm.max_memory {
1051        validate_memory_format(max_mem).map_err(|_| ValidationError {
1052            kind: ValidationErrorKind::InvalidMemoryFormat {
1053                value: max_mem.clone(),
1054            },
1055            path: format!("services.{service_name}.wasm.max_memory"),
1056        })?;
1057    }
1058
1059    if wasm.min_instances > wasm.max_instances {
1060        return Err(ValidationError {
1061            kind: ValidationErrorKind::InvalidWasmInstanceRange {
1062                min: wasm.min_instances,
1063                max: wasm.max_instances,
1064            },
1065            path: format!("services.{service_name}.wasm"),
1066        });
1067    }
1068
1069    Ok(())
1070}
1071
1072/// Validate WASM capability restrictions against service type defaults.
1073fn validate_wasm_capabilities(
1074    service_name: &str,
1075    spec: &ServiceSpec,
1076    wasm: &crate::spec::types::WasmConfig,
1077) -> Result<(), ValidationError> {
1078    let Some(ref caps) = wasm.capabilities else {
1079        return Ok(());
1080    };
1081    let Some(defaults) = spec.service_type.default_wasm_capabilities() else {
1082        return Ok(());
1083    };
1084
1085    let checks: &[(&str, bool, bool)] = &[
1086        ("config", caps.config, defaults.config),
1087        ("keyvalue", caps.keyvalue, defaults.keyvalue),
1088        ("logging", caps.logging, defaults.logging),
1089        ("secrets", caps.secrets, defaults.secrets),
1090        ("metrics", caps.metrics, defaults.metrics),
1091        ("http_client", caps.http_client, defaults.http_client),
1092        ("cli", caps.cli, defaults.cli),
1093        ("filesystem", caps.filesystem, defaults.filesystem),
1094        ("sockets", caps.sockets, defaults.sockets),
1095    ];
1096
1097    for &(cap_name, requested, default) in checks {
1098        validate_capability_restriction(
1099            service_name,
1100            spec.service_type,
1101            cap_name,
1102            requested,
1103            default,
1104        )?;
1105    }
1106
1107    Ok(())
1108}
1109
1110/// Validate that `WasmHttp` services have at least one HTTP endpoint when endpoints exist.
1111fn validate_wasm_http_endpoints(
1112    service_name: &str,
1113    spec: &ServiceSpec,
1114) -> Result<(), ValidationError> {
1115    if spec.service_type == ServiceType::WasmHttp && !spec.endpoints.is_empty() {
1116        let has_http_endpoint = spec
1117            .endpoints
1118            .iter()
1119            .any(|e| matches!(e.protocol, Protocol::Http | Protocol::Https));
1120        if !has_http_endpoint {
1121            return Err(ValidationError {
1122                kind: ValidationErrorKind::WasmHttpMissingHttpEndpoint,
1123                path: format!("services.{service_name}.endpoints"),
1124            });
1125        }
1126    }
1127    Ok(())
1128}
1129
1130/// Validate that WASM preopens have non-empty source and target paths.
1131fn validate_wasm_preopens(
1132    service_name: &str,
1133    wasm: &crate::spec::types::WasmConfig,
1134) -> Result<(), ValidationError> {
1135    for (i, preopen) in wasm.preopens.iter().enumerate() {
1136        if preopen.source.is_empty() {
1137            return Err(ValidationError {
1138                kind: ValidationErrorKind::WasmPreopenEmpty {
1139                    index: i,
1140                    field: "source".to_string(),
1141                },
1142                path: format!("services.{service_name}.wasm.preopens[{i}].source"),
1143            });
1144        }
1145        if preopen.target.is_empty() {
1146            return Err(ValidationError {
1147                kind: ValidationErrorKind::WasmPreopenEmpty {
1148                    index: i,
1149                    field: "target".to_string(),
1150                },
1151                path: format!("services.{service_name}.wasm.preopens[{i}].target"),
1152            });
1153        }
1154    }
1155    Ok(())
1156}
1157
1158/// Validate that a requested capability does not exceed the default for the service type.
1159///
1160/// Users can only restrict capabilities from their defaults, not grant new ones.
1161///
1162/// # Errors
1163///
1164/// Returns a validation error if the user requests a capability that is not
1165/// available for this WASM service type.
1166fn validate_capability_restriction(
1167    service_name: &str,
1168    service_type: ServiceType,
1169    cap_name: &str,
1170    requested: bool,
1171    default: bool,
1172) -> Result<(), ValidationError> {
1173    if requested && !default {
1174        return Err(ValidationError {
1175            kind: ValidationErrorKind::WasmCapabilityNotAvailable {
1176                capability: cap_name.to_string(),
1177                service_type: format!("{service_type:?}"),
1178            },
1179            path: format!("services.{service_name}.wasm.capabilities.{cap_name}"),
1180        });
1181    }
1182    Ok(())
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188    use crate::spec::types::{ExposeType, Protocol};
1189
1190    // Version validation tests
1191    #[test]
1192    fn test_validate_version_valid() {
1193        assert!(validate_version("v1").is_ok());
1194    }
1195
1196    #[test]
1197    fn test_validate_version_invalid_v2() {
1198        let result = validate_version("v2");
1199        assert!(result.is_err());
1200        let err = result.unwrap_err();
1201        assert!(matches!(
1202            err.kind,
1203            ValidationErrorKind::InvalidVersion { found } if found == "v2"
1204        ));
1205    }
1206
1207    #[test]
1208    fn test_validate_version_empty() {
1209        let result = validate_version("");
1210        assert!(result.is_err());
1211        let err = result.unwrap_err();
1212        assert!(matches!(
1213            err.kind,
1214            ValidationErrorKind::InvalidVersion { found } if found.is_empty()
1215        ));
1216    }
1217
1218    // Deployment name validation tests
1219    #[test]
1220    fn test_validate_deployment_name_valid() {
1221        assert!(validate_deployment_name("my-app").is_ok());
1222        assert!(validate_deployment_name("api").is_ok());
1223        assert!(validate_deployment_name("my-service-123").is_ok());
1224        assert!(validate_deployment_name("a1b").is_ok());
1225    }
1226
1227    #[test]
1228    fn test_validate_deployment_name_too_short() {
1229        assert!(validate_deployment_name("ab").is_err());
1230        assert!(validate_deployment_name("a").is_err());
1231        assert!(validate_deployment_name("").is_err());
1232    }
1233
1234    #[test]
1235    fn test_validate_deployment_name_too_long() {
1236        let long_name = "a".repeat(64);
1237        assert!(validate_deployment_name(&long_name).is_err());
1238    }
1239
1240    #[test]
1241    fn test_validate_deployment_name_invalid_chars() {
1242        assert!(validate_deployment_name("my_app").is_err()); // underscore
1243        assert!(validate_deployment_name("my.app").is_err()); // dot
1244        assert!(validate_deployment_name("my app").is_err()); // space
1245        assert!(validate_deployment_name("my@app").is_err()); // special char
1246    }
1247
1248    #[test]
1249    fn test_validate_deployment_name_must_start_alphanumeric() {
1250        assert!(validate_deployment_name("-myapp").is_err());
1251        assert!(validate_deployment_name("_myapp").is_err());
1252    }
1253
1254    // Image name validation tests
1255    #[test]
1256    fn test_validate_image_name_valid() {
1257        assert!(validate_image_name("nginx:latest").is_ok());
1258        assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
1259        assert!(validate_image_name("ubuntu").is_ok());
1260    }
1261
1262    #[test]
1263    fn test_validate_image_name_empty() {
1264        let result = validate_image_name("");
1265        assert!(result.is_err());
1266        assert!(matches!(
1267            result.unwrap_err().kind,
1268            ValidationErrorKind::EmptyImageName
1269        ));
1270    }
1271
1272    #[test]
1273    fn test_validate_image_name_whitespace_only() {
1274        assert!(validate_image_name("   ").is_err());
1275        assert!(validate_image_name("\t\n").is_err());
1276    }
1277
1278    // CPU validation tests
1279    #[test]
1280    fn test_validate_cpu_valid() {
1281        assert!(validate_cpu(&0.5).is_ok());
1282        assert!(validate_cpu(&1.0).is_ok());
1283        assert!(validate_cpu(&2.0).is_ok());
1284        assert!(validate_cpu(&0.001).is_ok());
1285    }
1286
1287    #[test]
1288    fn test_validate_cpu_zero() {
1289        let result = validate_cpu(&0.0);
1290        assert!(result.is_err());
1291        assert!(matches!(
1292            result.unwrap_err().kind,
1293            ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
1294        ));
1295    }
1296
1297    #[test]
1298    #[allow(clippy::float_cmp)]
1299    fn test_validate_cpu_negative() {
1300        let result = validate_cpu(&-1.0);
1301        assert!(result.is_err());
1302        assert!(matches!(
1303            result.unwrap_err().kind,
1304            ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
1305        ));
1306    }
1307
1308    // Memory format validation tests
1309    #[test]
1310    fn test_validate_memory_format_valid() {
1311        assert!(validate_memory_format("512Mi").is_ok());
1312        assert!(validate_memory_format("1Gi").is_ok());
1313        assert!(validate_memory_format("2Ti").is_ok());
1314        assert!(validate_memory_format("256Ki").is_ok());
1315        assert!(validate_memory_format("4096Mi").is_ok());
1316    }
1317
1318    #[test]
1319    fn test_validate_memory_format_invalid_suffix() {
1320        assert!(validate_memory_format("512MB").is_err());
1321        assert!(validate_memory_format("1GB").is_err());
1322        assert!(validate_memory_format("512").is_err());
1323        assert!(validate_memory_format("512m").is_err());
1324    }
1325
1326    #[test]
1327    fn test_validate_memory_format_no_number() {
1328        assert!(validate_memory_format("Mi").is_err());
1329        assert!(validate_memory_format("Gi").is_err());
1330    }
1331
1332    #[test]
1333    fn test_validate_memory_format_invalid_number() {
1334        assert!(validate_memory_format("-512Mi").is_err());
1335        assert!(validate_memory_format("0Mi").is_err());
1336        assert!(validate_memory_format("abcMi").is_err());
1337    }
1338
1339    // Port validation tests
1340    #[test]
1341    fn test_validate_port_valid() {
1342        assert!(validate_port(&1).is_ok());
1343        assert!(validate_port(&80).is_ok());
1344        assert!(validate_port(&443).is_ok());
1345        assert!(validate_port(&8080).is_ok());
1346        assert!(validate_port(&65535).is_ok());
1347    }
1348
1349    #[test]
1350    fn test_validate_port_zero() {
1351        let result = validate_port(&0);
1352        assert!(result.is_err());
1353        assert!(matches!(
1354            result.unwrap_err().kind,
1355            ValidationErrorKind::InvalidPort { port } if port == 0
1356        ));
1357    }
1358
1359    // Note: u16 cannot be negative, and max value 65535 is valid,
1360    // so we only need to test 0 as the invalid case
1361
1362    // Unique endpoints validation tests
1363    #[test]
1364    fn test_validate_unique_endpoints_valid() {
1365        let endpoints = vec![
1366            EndpointSpec {
1367                name: "http".to_string(),
1368                protocol: Protocol::Http,
1369                port: 8080,
1370                target_port: None,
1371                path: None,
1372                host: None,
1373                expose: ExposeType::Public,
1374                stream: None,
1375                target_role: None,
1376                tunnel: None,
1377            },
1378            EndpointSpec {
1379                name: "grpc".to_string(),
1380                protocol: Protocol::Tcp,
1381                port: 9090,
1382                target_port: None,
1383                path: None,
1384                host: None,
1385                expose: ExposeType::Internal,
1386                stream: None,
1387                target_role: None,
1388                tunnel: None,
1389            },
1390        ];
1391        assert!(validate_unique_endpoints(&endpoints).is_ok());
1392    }
1393
1394    #[test]
1395    fn test_validate_unique_endpoints_empty() {
1396        let endpoints: Vec<EndpointSpec> = vec![];
1397        assert!(validate_unique_endpoints(&endpoints).is_ok());
1398    }
1399
1400    #[test]
1401    fn test_validate_unique_endpoints_duplicates() {
1402        let endpoints = vec![
1403            EndpointSpec {
1404                name: "http".to_string(),
1405                protocol: Protocol::Http,
1406                port: 8080,
1407                target_port: None,
1408                path: None,
1409                host: None,
1410                expose: ExposeType::Public,
1411                stream: None,
1412                target_role: None,
1413                tunnel: None,
1414            },
1415            EndpointSpec {
1416                name: "http".to_string(), // duplicate name
1417                protocol: Protocol::Https,
1418                port: 8443,
1419                target_port: None,
1420                path: None,
1421                host: None,
1422                expose: ExposeType::Public,
1423                stream: None,
1424                target_role: None,
1425                tunnel: None,
1426            },
1427        ];
1428        let result = validate_unique_endpoints(&endpoints);
1429        assert!(result.is_err());
1430        assert!(matches!(
1431            result.unwrap_err().kind,
1432            ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
1433        ));
1434    }
1435
1436    // Scale range validation tests
1437    #[test]
1438    fn test_validate_scale_range_valid() {
1439        assert!(validate_scale_range(1, 10).is_ok());
1440        assert!(validate_scale_range(1, 1).is_ok()); // min == max is valid
1441        assert!(validate_scale_range(0, 5).is_ok());
1442        assert!(validate_scale_range(5, 100).is_ok());
1443    }
1444
1445    #[test]
1446    fn test_validate_scale_range_min_greater_than_max() {
1447        let result = validate_scale_range(10, 5);
1448        assert!(result.is_err());
1449        let err = result.unwrap_err();
1450        assert!(matches!(
1451            err.kind,
1452            ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
1453        ));
1454    }
1455
1456    #[test]
1457    fn test_validate_scale_range_large_gap() {
1458        // Large gap between min and max should still be valid
1459        assert!(validate_scale_range(1, 1000).is_ok());
1460    }
1461
1462    // Cron schedule validation tests
1463    // Note: The `cron` crate uses 7-field format: "sec min hour day-of-month month day-of-week year"
1464    #[test]
1465    fn test_validate_schedule_wrapper_valid() {
1466        // Valid 7-field cron expressions (sec min hour dom month dow year)
1467        assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); // Daily at midnight
1468        assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); // Every 5 minutes
1469        assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); // Weekdays at noon
1470        assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); // Monthly at 2:30am on 1st
1471        assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
1472        // Every 10 seconds
1473    }
1474
1475    #[test]
1476    fn test_validate_schedule_wrapper_invalid() {
1477        // Invalid cron expressions
1478        assert!(validate_schedule_wrapper(&String::new()).is_err()); // Empty
1479        assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); // Plain text
1480        assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); // 5-field (standard unix cron) not supported
1481        assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
1482        // Invalid second (60)
1483    }
1484
1485    // Secret reference validation tests
1486    #[test]
1487    fn test_validate_secret_reference_plain_values() {
1488        // Plain values should pass (not secret refs)
1489        assert!(validate_secret_reference("my-value").is_ok());
1490        assert!(validate_secret_reference("").is_ok());
1491        assert!(validate_secret_reference("some string").is_ok());
1492        assert!(validate_secret_reference("$E:MY_VAR").is_ok()); // Host env ref, not secret
1493    }
1494
1495    #[test]
1496    fn test_validate_secret_reference_valid() {
1497        // Valid secret references
1498        assert!(validate_secret_reference("$S:my-secret").is_ok());
1499        assert!(validate_secret_reference("$S:api_key").is_ok());
1500        assert!(validate_secret_reference("$S:MySecret123").is_ok());
1501        assert!(validate_secret_reference("$S:a").is_ok()); // Single letter is valid
1502    }
1503
1504    #[test]
1505    fn test_validate_secret_reference_cross_service() {
1506        // Valid cross-service references
1507        assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
1508        assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
1509        assert!(validate_secret_reference("$S:@svc/secret").is_ok());
1510    }
1511
1512    #[test]
1513    fn test_validate_secret_reference_empty_after_prefix() {
1514        // Empty after $S:
1515        assert!(validate_secret_reference("$S:").is_err());
1516    }
1517
1518    #[test]
1519    fn test_validate_secret_reference_must_start_with_letter() {
1520        // Secret name must start with letter
1521        assert!(validate_secret_reference("$S:123-secret").is_err());
1522        assert!(validate_secret_reference("$S:-my-secret").is_err());
1523        assert!(validate_secret_reference("$S:_underscore").is_err());
1524    }
1525
1526    #[test]
1527    fn test_validate_secret_reference_invalid_chars() {
1528        // Invalid characters in secret name
1529        assert!(validate_secret_reference("$S:my.secret").is_err());
1530        assert!(validate_secret_reference("$S:my secret").is_err());
1531        assert!(validate_secret_reference("$S:my@secret").is_err());
1532    }
1533
1534    #[test]
1535    fn test_validate_secret_reference_cross_service_invalid() {
1536        // Missing slash in cross-service ref
1537        assert!(validate_secret_reference("$S:@service").is_err());
1538        // Empty service name
1539        assert!(validate_secret_reference("$S:@/secret").is_err());
1540        // Empty secret name
1541        assert!(validate_secret_reference("$S:@service/").is_err());
1542        // Service name must start with letter
1543        assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1544    }
1545
1546    // =========================================================================
1547    // Tunnel validation tests
1548    // =========================================================================
1549
1550    #[test]
1551    fn test_validate_tunnel_ttl_valid() {
1552        assert!(validate_tunnel_ttl("30m").is_ok());
1553        assert!(validate_tunnel_ttl("4h").is_ok());
1554        assert!(validate_tunnel_ttl("1d").is_ok());
1555        assert!(validate_tunnel_ttl("1h 30m").is_ok());
1556        assert!(validate_tunnel_ttl("2h30m").is_ok());
1557    }
1558
1559    #[test]
1560    fn test_validate_tunnel_ttl_invalid() {
1561        assert!(validate_tunnel_ttl("").is_err());
1562        assert!(validate_tunnel_ttl("invalid").is_err());
1563        assert!(validate_tunnel_ttl("30").is_err()); // Missing unit
1564        assert!(validate_tunnel_ttl("-1h").is_err()); // Negative
1565    }
1566
1567    #[test]
1568    fn test_validate_tunnel_definition_valid() {
1569        let tunnel = TunnelDefinition {
1570            from: "node-a".to_string(),
1571            to: "node-b".to_string(),
1572            local_port: 8080,
1573            remote_port: 9000,
1574            protocol: crate::spec::types::TunnelProtocol::Tcp,
1575            expose: ExposeType::Internal,
1576        };
1577        assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
1578    }
1579
1580    #[test]
1581    fn test_validate_tunnel_definition_local_port_zero() {
1582        let tunnel = TunnelDefinition {
1583            from: "node-a".to_string(),
1584            to: "node-b".to_string(),
1585            local_port: 0,
1586            remote_port: 9000,
1587            protocol: crate::spec::types::TunnelProtocol::Tcp,
1588            expose: ExposeType::Internal,
1589        };
1590        let result = validate_tunnel_definition("test-tunnel", &tunnel);
1591        assert!(result.is_err());
1592        assert!(matches!(
1593            result.unwrap_err().kind,
1594            ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
1595        ));
1596    }
1597
1598    #[test]
1599    fn test_validate_tunnel_definition_remote_port_zero() {
1600        let tunnel = TunnelDefinition {
1601            from: "node-a".to_string(),
1602            to: "node-b".to_string(),
1603            local_port: 8080,
1604            remote_port: 0,
1605            protocol: crate::spec::types::TunnelProtocol::Tcp,
1606            expose: ExposeType::Internal,
1607        };
1608        let result = validate_tunnel_definition("test-tunnel", &tunnel);
1609        assert!(result.is_err());
1610        assert!(matches!(
1611            result.unwrap_err().kind,
1612            ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
1613        ));
1614    }
1615
1616    #[test]
1617    fn test_validate_endpoint_tunnel_config_valid() {
1618        let config = EndpointTunnelConfig {
1619            enabled: true,
1620            from: Some("node-1".to_string()),
1621            to: Some("ingress".to_string()),
1622            remote_port: 8080,
1623            expose: Some(ExposeType::Public),
1624            access: None,
1625        };
1626        assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1627    }
1628
1629    #[test]
1630    fn test_validate_endpoint_tunnel_config_with_access() {
1631        let config = EndpointTunnelConfig {
1632            enabled: true,
1633            from: None,
1634            to: None,
1635            remote_port: 0, // auto-assign
1636            expose: None,
1637            access: Some(TunnelAccessConfig {
1638                enabled: true,
1639                max_ttl: Some("4h".to_string()),
1640                audit: true,
1641            }),
1642        };
1643        assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1644    }
1645
1646    #[test]
1647    fn test_validate_endpoint_tunnel_config_invalid_ttl() {
1648        let config = EndpointTunnelConfig {
1649            enabled: true,
1650            from: None,
1651            to: None,
1652            remote_port: 0,
1653            expose: None,
1654            access: Some(TunnelAccessConfig {
1655                enabled: true,
1656                max_ttl: Some("invalid".to_string()),
1657                audit: false,
1658            }),
1659        };
1660        let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
1661        assert!(result.is_err());
1662        assert!(matches!(
1663            result.unwrap_err().kind,
1664            ValidationErrorKind::InvalidTunnelTtl { .. }
1665        ));
1666    }
1667
1668    // =========================================================================
1669    // WASM validation tests
1670    // =========================================================================
1671
1672    #[test]
1673    fn test_validate_capability_restriction_allowed() {
1674        // Requesting a capability that defaults to true is fine
1675        let result = validate_capability_restriction(
1676            "test-svc",
1677            ServiceType::WasmHttp,
1678            "config",
1679            true,
1680            true,
1681        );
1682        assert!(result.is_ok());
1683    }
1684
1685    #[test]
1686    fn test_validate_capability_restriction_restricting_is_ok() {
1687        // Restricting a capability (setting false when default is true) is fine
1688        let result = validate_capability_restriction(
1689            "test-svc",
1690            ServiceType::WasmHttp,
1691            "config",
1692            false,
1693            true,
1694        );
1695        assert!(result.is_ok());
1696    }
1697
1698    #[test]
1699    fn test_validate_capability_restriction_granting_not_allowed() {
1700        // Requesting a capability that defaults to false is not allowed
1701        let result = validate_capability_restriction(
1702            "test-svc",
1703            ServiceType::WasmHttp,
1704            "secrets",
1705            true,
1706            false,
1707        );
1708        assert!(result.is_err());
1709        assert!(matches!(
1710            result.unwrap_err().kind,
1711            ValidationErrorKind::WasmCapabilityNotAvailable { ref capability, .. }
1712            if capability == "secrets"
1713        ));
1714    }
1715
1716    #[test]
1717    fn test_validate_capability_restriction_both_false_is_ok() {
1718        // Both false is fine (not requesting, not available)
1719        let result = validate_capability_restriction(
1720            "test-svc",
1721            ServiceType::WasmTransformer,
1722            "sockets",
1723            false,
1724            false,
1725        );
1726        assert!(result.is_ok());
1727    }
1728
1729    // =========================================================================
1730    // Swarm-sharding validation tests
1731    // =========================================================================
1732
1733    use crate::spec::types::{GpuSpec, ShardingSpec, SwarmPeer, SwarmRole};
1734    use std::collections::HashMap;
1735
1736    /// Build a `GpuSpec` carrying the given `ShardingSpec`, using GPU defaults
1737    /// otherwise (1 GPU, nvidia).
1738    fn gpu_with_sharding(sharding: ShardingSpec) -> GpuSpec {
1739        GpuSpec {
1740            count: 1,
1741            vendor: "nvidia".to_string(),
1742            mode: None,
1743            model: None,
1744            scheduling: None,
1745            distributed: None,
1746            sharing: None,
1747            mps_pipe_dir: None,
1748            mps_log_dir: None,
1749            time_slice_index: None,
1750            time_slicing_config_path: None,
1751            sharding: Some(sharding),
1752        }
1753    }
1754
1755    /// Build a swarm stage `ShardingSpec` covering `[layer_start, layer_end)`
1756    /// of a `layer_count`-layer model.
1757    fn stage_sharding(
1758        swarm_id: &str,
1759        layer_start: u32,
1760        layer_end: u32,
1761        layer_count: u32,
1762    ) -> ShardingSpec {
1763        ShardingSpec {
1764            swarm_id: swarm_id.to_string(),
1765            layer_start,
1766            layer_end,
1767            layer_count,
1768            role: SwarmRole::Stage,
1769            manifest_ref: None,
1770            peers: Vec::new(),
1771            coordinator: None,
1772        }
1773    }
1774
1775    /// Build a swarm coordinator `ShardingSpec`. A coordinator holds no layers
1776    /// itself; it drives the ring of stages.
1777    fn coordinator_sharding(
1778        swarm_id: &str,
1779        layer_count: u32,
1780        peers: Vec<SwarmPeer>,
1781    ) -> ShardingSpec {
1782        ShardingSpec {
1783            swarm_id: swarm_id.to_string(),
1784            layer_start: 0,
1785            layer_end: 0,
1786            layer_count,
1787            role: SwarmRole::Coordinator,
1788            manifest_ref: None,
1789            peers,
1790            coordinator: None,
1791        }
1792    }
1793
1794    /// Build a service whose `resources.gpu.sharding` is set to `sharding`.
1795    fn swarm_service(sharding: ShardingSpec) -> ServiceSpec {
1796        let mut svc = ServiceSpec::minimal("svc", "registry.example.com/model:latest");
1797        svc.resources.gpu = Some(gpu_with_sharding(sharding));
1798        svc
1799    }
1800
1801    /// Assemble a `DeploymentSpec` from a `(name, service)` list.
1802    fn swarm_deployment(services: Vec<(&str, ServiceSpec)>) -> DeploymentSpec {
1803        let mut map: HashMap<String, ServiceSpec> = HashMap::new();
1804        for (name, svc) in services {
1805            map.insert(name.to_string(), svc);
1806        }
1807        DeploymentSpec {
1808            version: "v1".to_string(),
1809            deployment: "swarm-test".to_string(),
1810            services: map,
1811            externals: HashMap::new(),
1812            tunnels: HashMap::new(),
1813            api: crate::spec::types::ApiSpec::default(),
1814            environment: None,
1815            project: None,
1816        }
1817    }
1818
1819    /// A well-formed 3-stage swarm covering layers 0..36 plus one coordinator
1820    /// whose peers reference the three real stage services.
1821    fn well_formed_swarm() -> DeploymentSpec {
1822        let peers = vec![
1823            SwarmPeer {
1824                service: "stage-a".to_string(),
1825                layer_start: 0,
1826                layer_end: 12,
1827            },
1828            SwarmPeer {
1829                service: "stage-b".to_string(),
1830                layer_start: 12,
1831                layer_end: 24,
1832            },
1833            SwarmPeer {
1834                service: "stage-c".to_string(),
1835                layer_start: 24,
1836                layer_end: 36,
1837            },
1838        ];
1839        swarm_deployment(vec![
1840            ("stage-a", swarm_service(stage_sharding("s1", 0, 12, 36))),
1841            ("stage-b", swarm_service(stage_sharding("s1", 12, 24, 36))),
1842            ("stage-c", swarm_service(stage_sharding("s1", 24, 36, 36))),
1843            (
1844                "coord",
1845                swarm_service(coordinator_sharding("s1", 36, peers)),
1846            ),
1847        ])
1848    }
1849
1850    #[test]
1851    fn test_validate_swarm_well_formed_accepts() {
1852        let spec = well_formed_swarm();
1853        assert!(validate_swarm(&spec).is_ok());
1854    }
1855
1856    #[test]
1857    fn test_validate_swarm_no_sharding_is_ok() {
1858        // A deployment with no sharding at all must pass.
1859        let spec = swarm_deployment(vec![(
1860            "plain",
1861            ServiceSpec::minimal("plain", "registry.example.com/app:latest"),
1862        )]);
1863        assert!(validate_swarm(&spec).is_ok());
1864    }
1865
1866    #[test]
1867    fn test_validate_swarm_coverage_gap_rejected() {
1868        // 0..12 then 16..36 leaves [12, 16) unassigned.
1869        let spec = swarm_deployment(vec![
1870            ("stage-a", swarm_service(stage_sharding("s1", 0, 12, 36))),
1871            ("stage-b", swarm_service(stage_sharding("s1", 16, 36, 36))),
1872            (
1873                "coord",
1874                swarm_service(coordinator_sharding("s1", 36, Vec::new())),
1875            ),
1876        ]);
1877        assert!(validate_swarm(&spec).is_err());
1878    }
1879
1880    #[test]
1881    fn test_validate_swarm_coverage_overlap_rejected() {
1882        // 0..12 then 10..36 overlaps on [10, 12).
1883        let spec = swarm_deployment(vec![
1884            ("stage-a", swarm_service(stage_sharding("s1", 0, 12, 36))),
1885            ("stage-b", swarm_service(stage_sharding("s1", 10, 36, 36))),
1886            (
1887                "coord",
1888                swarm_service(coordinator_sharding("s1", 36, Vec::new())),
1889            ),
1890        ]);
1891        assert!(validate_swarm(&spec).is_err());
1892    }
1893
1894    #[test]
1895    fn test_validate_swarm_last_end_below_layer_count_rejected() {
1896        // Coverage ends at 30 but layer_count is 36.
1897        let spec = swarm_deployment(vec![
1898            ("stage-a", swarm_service(stage_sharding("s1", 0, 12, 36))),
1899            ("stage-b", swarm_service(stage_sharding("s1", 12, 30, 36))),
1900            (
1901                "coord",
1902                swarm_service(coordinator_sharding("s1", 36, Vec::new())),
1903            ),
1904        ]);
1905        assert!(validate_swarm(&spec).is_err());
1906    }
1907
1908    #[test]
1909    fn test_validate_swarm_first_start_nonzero_rejected() {
1910        // First stage starts at 4 instead of 0.
1911        let spec = swarm_deployment(vec![
1912            ("stage-a", swarm_service(stage_sharding("s1", 4, 20, 36))),
1913            ("stage-b", swarm_service(stage_sharding("s1", 20, 36, 36))),
1914            (
1915                "coord",
1916                swarm_service(coordinator_sharding("s1", 36, Vec::new())),
1917            ),
1918        ]);
1919        assert!(validate_swarm(&spec).is_err());
1920    }
1921
1922    #[test]
1923    fn test_validate_swarm_two_coordinators_rejected() {
1924        let spec = swarm_deployment(vec![
1925            ("stage-a", swarm_service(stage_sharding("s1", 0, 18, 36))),
1926            ("stage-b", swarm_service(stage_sharding("s1", 18, 36, 36))),
1927            (
1928                "coord-1",
1929                swarm_service(coordinator_sharding("s1", 36, Vec::new())),
1930            ),
1931            (
1932                "coord-2",
1933                swarm_service(coordinator_sharding("s1", 36, Vec::new())),
1934            ),
1935        ]);
1936        assert!(validate_swarm(&spec).is_err());
1937    }
1938
1939    #[test]
1940    fn test_validate_swarm_zero_coordinators_rejected() {
1941        let spec = swarm_deployment(vec![
1942            ("stage-a", swarm_service(stage_sharding("s1", 0, 18, 36))),
1943            ("stage-b", swarm_service(stage_sharding("s1", 18, 36, 36))),
1944        ]);
1945        assert!(validate_swarm(&spec).is_err());
1946    }
1947
1948    #[test]
1949    fn test_validate_swarm_unknown_peer_service_rejected() {
1950        // Coordinator references a peer service that does not exist in the spec.
1951        let peers = vec![SwarmPeer {
1952            service: "does-not-exist".to_string(),
1953            layer_start: 0,
1954            layer_end: 36,
1955        }];
1956        let spec = swarm_deployment(vec![
1957            ("stage-a", swarm_service(stage_sharding("s1", 0, 36, 36))),
1958            (
1959                "coord",
1960                swarm_service(coordinator_sharding("s1", 36, peers)),
1961            ),
1962        ]);
1963        assert!(validate_swarm(&spec).is_err());
1964    }
1965
1966    #[test]
1967    fn test_validate_swarm_inconsistent_layer_count_rejected() {
1968        // stage-a declares layer_count 36, stage-b declares 30.
1969        let spec = swarm_deployment(vec![
1970            ("stage-a", swarm_service(stage_sharding("s1", 0, 12, 36))),
1971            ("stage-b", swarm_service(stage_sharding("s1", 12, 30, 30))),
1972            (
1973                "coord",
1974                swarm_service(coordinator_sharding("s1", 36, Vec::new())),
1975            ),
1976        ]);
1977        assert!(validate_swarm(&spec).is_err());
1978    }
1979
1980    #[test]
1981    fn test_swarm_sharding_yaml_round_trip() {
1982        // A minimal deployment whose service carries resources.gpu.sharding must
1983        // parse + validate via from_yaml_str, and re-serializing then re-parsing
1984        // must preserve the sharding spec exactly.
1985        let yaml = r"
1986version: v1
1987deployment: swarm-rt
1988services:
1989  stage-a:
1990    image:
1991      name: registry.example.com/model:latest
1992    resources:
1993      gpu:
1994        count: 1
1995        vendor: nvidia
1996        sharding:
1997          swarm_id: s1
1998          layer_start: 0
1999          layer_end: 36
2000          layer_count: 36
2001          role: stage
2002  coord:
2003    image:
2004      name: registry.example.com/model:latest
2005    resources:
2006      gpu:
2007        count: 1
2008        vendor: nvidia
2009        sharding:
2010          swarm_id: s1
2011          layer_start: 0
2012          layer_end: 0
2013          layer_count: 36
2014          role: coordinator
2015          peers:
2016            - service: stage-a
2017              layer_start: 0
2018              layer_end: 36
2019";
2020        let spec = crate::spec::from_yaml_str(yaml).expect("well-formed swarm spec should parse");
2021
2022        let coord_sharding = spec
2023            .services
2024            .get("coord")
2025            .and_then(|s| s.resources.gpu.as_ref())
2026            .and_then(|g| g.sharding.as_ref())
2027            .expect("coord must carry gpu.sharding");
2028        assert_eq!(coord_sharding.swarm_id, "s1");
2029        assert_eq!(coord_sharding.layer_count, 36);
2030        assert_eq!(coord_sharding.role, SwarmRole::Coordinator);
2031        assert_eq!(coord_sharding.peers.len(), 1);
2032        assert_eq!(coord_sharding.peers[0].service, "stage-a");
2033
2034        // serde round-trip via JSON: re-serialize the parsed spec and parse it
2035        // back; the sharding must survive byte-for-byte as an equal value.
2036        let json = serde_json::to_string(&spec).expect("spec serializes to JSON");
2037        let reparsed: DeploymentSpec =
2038            serde_json::from_str(&json).expect("spec round-trips through JSON");
2039        assert_eq!(spec, reparsed);
2040
2041        let rt_sharding = reparsed
2042            .services
2043            .get("stage-a")
2044            .and_then(|s| s.resources.gpu.as_ref())
2045            .and_then(|g| g.sharding.as_ref())
2046            .expect("stage-a must carry gpu.sharding after round-trip");
2047        assert_eq!(rt_sharding.layer_start, 0);
2048        assert_eq!(rt_sharding.layer_end, 36);
2049        assert_eq!(rt_sharding.layer_count, 36);
2050        assert_eq!(rt_sharding.role, SwarmRole::Stage);
2051    }
2052}