1use 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
15fn 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
30pub 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
46pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
52 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 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 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
83pub 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
100pub 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#[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
155pub 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
172pub 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 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
223pub 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
238pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
258 if !value.starts_with("$S:") {
260 return Ok(());
261 }
262
263 let secret_ref = &value[3..]; 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 let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
274 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 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 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 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 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#[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
377pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
387 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
398pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
404 validate_storage_name(name)
405}
406
407pub 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#[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 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 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 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 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 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
606pub 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
629pub 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
641pub fn validate_service_schedule(
647 service_name: &str,
648 spec: &ServiceSpec,
649) -> Result<(), ValidationError> {
650 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 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
669pub 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
691pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
702 if name.len() < 3 || name.len() > 63 {
704 return Err(ValidationError {
705 kind: ValidationErrorKind::EmptyDeploymentName,
706 path: "deployment".to_string(),
707 });
708 }
709
710 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 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
733pub 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
753pub 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
772pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
781 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
783
784 let suffix_match = VALID_SUFFIXES
786 .iter()
787 .find(|&&suffix| value.ends_with(suffix));
788
789 match suffix_match {
790 Some(suffix) => {
791 let numeric_part = &value[..value.len() - suffix.len()];
793
794 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
814pub 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
835pub 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
857pub 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
876pub 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
894pub 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
917pub fn validate_endpoint_tunnel_config(
923 config: &EndpointTunnelConfig,
924 path: &str,
925) -> Result<(), ValidationError> {
926 if let Some(ref access) = config.access {
931 validate_tunnel_access_config(access, path)?;
932 }
933
934 Ok(())
935}
936
937pub fn validate_tunnel_definition(
943 name: &str,
944 tunnel: &TunnelDefinition,
945) -> Result<(), ValidationError> {
946 let path = format!("tunnels.{name}");
947
948 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 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
973pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
979 for (name, tunnel) in &spec.tunnels {
981 validate_tunnel_definition(name, tunnel)?;
982 }
983
984 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
997pub 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
1013pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
1027 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
1045fn 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
1072fn 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
1110fn 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
1130fn 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
1158fn 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 #[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 #[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()); assert!(validate_deployment_name("my.app").is_err()); assert!(validate_deployment_name("my app").is_err()); assert!(validate_deployment_name("my@app").is_err()); }
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 #[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 #[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 #[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 #[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 #[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(), 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 #[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()); 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 assert!(validate_scale_range(1, 1000).is_ok());
1460 }
1461
1462 #[test]
1465 fn test_validate_schedule_wrapper_valid() {
1466 assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
1472 }
1474
1475 #[test]
1476 fn test_validate_schedule_wrapper_invalid() {
1477 assert!(validate_schedule_wrapper(&String::new()).is_err()); assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
1482 }
1484
1485 #[test]
1487 fn test_validate_secret_reference_plain_values() {
1488 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()); }
1494
1495 #[test]
1496 fn test_validate_secret_reference_valid() {
1497 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()); }
1503
1504 #[test]
1505 fn test_validate_secret_reference_cross_service() {
1506 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 assert!(validate_secret_reference("$S:").is_err());
1516 }
1517
1518 #[test]
1519 fn test_validate_secret_reference_must_start_with_letter() {
1520 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 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 assert!(validate_secret_reference("$S:@service").is_err());
1538 assert!(validate_secret_reference("$S:@/secret").is_err());
1540 assert!(validate_secret_reference("$S:@service/").is_err());
1542 assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1544 }
1545
1546 #[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()); assert!(validate_tunnel_ttl("-1h").is_err()); }
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, 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 #[test]
1673 fn test_validate_capability_restriction_allowed() {
1674 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 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 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 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 use crate::spec::types::{GpuSpec, ShardingSpec, SwarmPeer, SwarmRole};
1734 use std::collections::HashMap;
1735
1736 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}