1mod error;
6mod types;
7mod validate;
8
9pub use error::*;
10pub use types::*;
11pub use validate::*;
12
13use validator::Validate;
14
15pub fn from_yaml_str(yaml: &str) -> Result<DeploymentSpec, SpecError> {
21 let spec: DeploymentSpec = serde_yml::from_str(yaml)?;
22
23 spec.validate().map_err(|e| {
25 SpecError::Validation(ValidationError {
26 kind: ValidationErrorKind::Generic {
27 message: e.to_string(),
28 },
29 path: String::new(),
30 })
31 })?;
32
33 validate_dependencies(&spec)?;
35 validate_unique_service_endpoints(&spec)?;
36 validate_cron_schedules(&spec)?;
37 validate_tunnels(&spec)?;
38
39 Ok(spec)
40}
41
42pub fn from_yaml_file(path: &std::path::Path) -> Result<DeploymentSpec, SpecError> {
48 let content = std::fs::read_to_string(path)?;
49 from_yaml_str(&content)
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55
56 #[test]
57 fn test_parse_from_yaml_str() {
58 let yaml = r#"
59version: v1
60deployment: test
61services:
62 hello:
63 rtype: service
64 image:
65 name: hello-world:latest
66"#;
67 let result = from_yaml_str(yaml);
68 assert!(result.is_ok());
69 let spec = result.unwrap();
70 assert_eq!(spec.version, "v1");
71 assert_eq!(spec.deployment, "test");
72 }
73
74 #[test]
79 fn test_invalid_version_rejected() {
80 let yaml = r#"
81version: v2
82deployment: my-app
83services:
84 hello:
85 image:
86 name: hello-world:latest
87"#;
88 let result = from_yaml_str(yaml);
89 assert!(result.is_err());
90 let err = result.unwrap_err();
91 let err_str = err.to_string();
92 assert!(
93 err_str.contains("version") || err_str.contains("v1"),
94 "Error should mention version: {}",
95 err_str
96 );
97 }
98
99 #[test]
100 fn test_empty_deployment_name_rejected() {
101 let yaml = r#"
102version: v1
103deployment: ab
104services:
105 hello:
106 image:
107 name: hello-world:latest
108"#;
109 let result = from_yaml_str(yaml);
110 assert!(result.is_err());
111 let err = result.unwrap_err();
112 let err_str = err.to_string();
113 assert!(
114 err_str.contains("deployment") || err_str.contains("3-63"),
115 "Error should mention deployment name: {}",
116 err_str
117 );
118 }
119
120 #[test]
121 fn test_invalid_port_zero_rejected() {
122 let yaml = r#"
123version: v1
124deployment: my-app
125services:
126 hello:
127 image:
128 name: hello-world:latest
129 endpoints:
130 - name: http
131 protocol: http
132 port: 0
133"#;
134 let result = from_yaml_str(yaml);
135 assert!(result.is_err());
136 let err = result.unwrap_err();
137 let err_str = err.to_string();
138 assert!(
139 err_str.contains("port"),
140 "Error should mention port: {}",
141 err_str
142 );
143 }
144
145 #[test]
146 fn test_unknown_dependency_rejected() {
147 let yaml = r#"
148version: v1
149deployment: my-app
150services:
151 api:
152 image:
153 name: api:latest
154 depends:
155 - service: database
156"#;
157 let result = from_yaml_str(yaml);
158 assert!(result.is_err());
159 let err = result.unwrap_err();
160 let err_str = err.to_string();
161 assert!(
162 err_str.contains("database") || err_str.contains("unknown"),
163 "Error should mention unknown dependency: {}",
164 err_str
165 );
166 }
167
168 #[test]
169 fn test_duplicate_endpoint_names_rejected() {
170 let yaml = r#"
171version: v1
172deployment: my-app
173services:
174 api:
175 image:
176 name: api:latest
177 endpoints:
178 - name: http
179 protocol: http
180 port: 8080
181 - name: http
182 protocol: https
183 port: 8443
184"#;
185 let result = from_yaml_str(yaml);
186 assert!(result.is_err());
187 let err = result.unwrap_err();
188 let err_str = err.to_string();
189 assert!(
190 err_str.contains("http") || err_str.contains("duplicate"),
191 "Error should mention duplicate endpoint: {}",
192 err_str
193 );
194 }
195
196 #[test]
197 fn test_invalid_scale_range_min_gt_max_rejected() {
198 let yaml = r#"
199version: v1
200deployment: my-app
201services:
202 api:
203 image:
204 name: api:latest
205 scale:
206 mode: adaptive
207 min: 10
208 max: 5
209"#;
210 let result = from_yaml_str(yaml);
211 assert!(result.is_err());
212 let err = result.unwrap_err();
213 let err_str = err.to_string();
214 assert!(
215 err_str.contains("scale") || err_str.contains("min") || err_str.contains("max"),
216 "Error should mention scale range: {}",
217 err_str
218 );
219 }
220
221 #[test]
222 fn test_invalid_cpu_zero_rejected() {
223 let yaml = r#"
224version: v1
225deployment: my-app
226services:
227 api:
228 image:
229 name: api:latest
230 resources:
231 cpu: 0
232"#;
233 let result = from_yaml_str(yaml);
234 assert!(result.is_err());
235 let err = result.unwrap_err();
236 let err_str = err.to_string();
237 assert!(
238 err_str.contains("cpu") || err_str.contains("CPU"),
239 "Error should mention CPU: {}",
240 err_str
241 );
242 }
243
244 #[test]
245 fn test_invalid_memory_format_rejected() {
246 let yaml = r#"
247version: v1
248deployment: my-app
249services:
250 api:
251 image:
252 name: api:latest
253 resources:
254 memory: 512MB
255"#;
256 let result = from_yaml_str(yaml);
257 assert!(result.is_err());
258 let err = result.unwrap_err();
259 let err_str = err.to_string();
260 assert!(
261 err_str.contains("memory"),
262 "Error should mention memory format: {}",
263 err_str
264 );
265 }
266
267 #[test]
268 fn test_empty_image_name_rejected() {
269 let yaml = r#"
270version: v1
271deployment: my-app
272services:
273 api:
274 image:
275 name: ""
276"#;
277 let result = from_yaml_str(yaml);
278 assert!(result.is_err());
279 let err = result.unwrap_err();
280 let err_str = err.to_string();
281 assert!(
282 err_str.contains("image") || err_str.contains("empty"),
283 "Error should mention empty image name: {}",
284 err_str
285 );
286 }
287
288 #[test]
289 fn test_valid_spec_passes_validation() {
290 let yaml = r#"
291version: v1
292deployment: my-production-app
293services:
294 api:
295 image:
296 name: ghcr.io/myorg/api:v1.2.3
297 resources:
298 cpu: 0.5
299 memory: 512Mi
300 endpoints:
301 - name: http
302 protocol: http
303 port: 8080
304 expose: public
305 - name: metrics
306 protocol: http
307 port: 9090
308 expose: internal
309 scale:
310 mode: adaptive
311 min: 2
312 max: 10
313 targets:
314 cpu: 70
315 depends:
316 - service: database
317 condition: healthy
318 database:
319 image:
320 name: postgres:15
321 endpoints:
322 - name: postgres
323 protocol: tcp
324 port: 5432
325 scale:
326 mode: fixed
327 replicas: 1
328"#;
329 let result = from_yaml_str(yaml);
330 assert!(result.is_ok(), "Valid spec should pass: {:?}", result);
331 let spec = result.unwrap();
332 assert_eq!(spec.version, "v1");
333 assert_eq!(spec.deployment, "my-production-app");
334 assert_eq!(spec.services.len(), 2);
335 }
336
337 #[test]
338 fn test_valid_dependency_passes() {
339 let yaml = r#"
340version: v1
341deployment: my-app
342services:
343 api:
344 image:
345 name: api:latest
346 depends:
347 - service: database
348 database:
349 image:
350 name: postgres:15
351"#;
352 let result = from_yaml_str(yaml);
353 assert!(result.is_ok(), "Valid dependency should pass: {:?}", result);
354 }
355
356 #[test]
361 fn test_valid_cron_job_with_schedule() {
362 let yaml = r#"
363version: v1
364deployment: my-app
365services:
366 cleanup:
367 rtype: cron
368 image:
369 name: cleanup:latest
370 schedule: "0 0 0 * * * *"
371"#;
372 let result = from_yaml_str(yaml);
373 assert!(result.is_ok(), "Valid cron job should pass: {:?}", result);
374 let spec = result.unwrap();
375 let cleanup = spec.services.get("cleanup").unwrap();
376 assert_eq!(cleanup.rtype, ResourceType::Cron);
377 assert_eq!(cleanup.schedule, Some("0 0 0 * * * *".to_string()));
378 }
379
380 #[test]
381 fn test_cron_without_schedule_rejected() {
382 let yaml = r#"
383version: v1
384deployment: my-app
385services:
386 cleanup:
387 rtype: cron
388 image:
389 name: cleanup:latest
390"#;
391 let result = from_yaml_str(yaml);
392 assert!(result.is_err());
393 let err = result.unwrap_err();
394 let err_str = err.to_string();
395 assert!(
396 err_str.contains("schedule") || err_str.contains("cron"),
397 "Error should mention missing schedule: {}",
398 err_str
399 );
400 }
401
402 #[test]
403 fn test_service_with_schedule_rejected() {
404 let yaml = r#"
405version: v1
406deployment: my-app
407services:
408 api:
409 rtype: service
410 image:
411 name: api:latest
412 schedule: "0 0 0 * * * *"
413"#;
414 let result = from_yaml_str(yaml);
415 assert!(result.is_err());
416 let err = result.unwrap_err();
417 let err_str = err.to_string();
418 assert!(
419 err_str.contains("schedule") || err_str.contains("cron"),
420 "Error should mention schedule/cron mismatch: {}",
421 err_str
422 );
423 }
424
425 #[test]
426 fn test_job_with_schedule_rejected() {
427 let yaml = r#"
428version: v1
429deployment: my-app
430services:
431 backup:
432 rtype: job
433 image:
434 name: backup:latest
435 schedule: "0 0 0 * * * *"
436"#;
437 let result = from_yaml_str(yaml);
438 assert!(result.is_err());
439 let err = result.unwrap_err();
440 let err_str = err.to_string();
441 assert!(
442 err_str.contains("schedule") || err_str.contains("cron"),
443 "Error should mention schedule/cron mismatch: {}",
444 err_str
445 );
446 }
447
448 #[test]
449 fn test_invalid_cron_expression_rejected() {
450 let yaml = r#"
451version: v1
452deployment: my-app
453services:
454 cleanup:
455 rtype: cron
456 image:
457 name: cleanup:latest
458 schedule: "not a valid cron"
459"#;
460 let result = from_yaml_str(yaml);
461 assert!(result.is_err());
462 let err = result.unwrap_err();
463 let err_str = err.to_string();
464 assert!(
465 err_str.contains("cron") || err_str.contains("schedule") || err_str.contains("invalid"),
466 "Error should mention invalid cron expression: {}",
467 err_str
468 );
469 }
470
471 #[test]
472 fn test_valid_extended_cron_expression() {
473 let yaml = r#"
474version: v1
475deployment: my-app
476services:
477 cleanup:
478 rtype: cron
479 image:
480 name: cleanup:latest
481 schedule: "0 30 2 * * * *"
482"#;
483 let result = from_yaml_str(yaml);
484 assert!(
485 result.is_ok(),
486 "Extended cron expression should be valid: {:?}",
487 result
488 );
489 }
490
491 #[test]
492 fn test_mixed_service_types_valid() {
493 let yaml = r#"
494version: v1
495deployment: my-app
496services:
497 api:
498 rtype: service
499 image:
500 name: api:latest
501 endpoints:
502 - name: http
503 protocol: http
504 port: 8080
505 backup:
506 rtype: job
507 image:
508 name: backup:latest
509 cleanup:
510 rtype: cron
511 image:
512 name: cleanup:latest
513 schedule: "0 0 0 * * * *"
514"#;
515 let result = from_yaml_str(yaml);
516 assert!(
517 result.is_ok(),
518 "Mixed service types should be valid: {:?}",
519 result
520 );
521 let spec = result.unwrap();
522 assert_eq!(spec.services.len(), 3);
523 assert_eq!(spec.services["api"].rtype, ResourceType::Service);
524 assert_eq!(spec.services["backup"].rtype, ResourceType::Job);
525 assert_eq!(spec.services["cleanup"].rtype, ResourceType::Cron);
526 }
527
528 #[test]
533 fn test_valid_endpoint_tunnel() {
534 let yaml = r#"
535version: v1
536deployment: my-app
537services:
538 api:
539 image:
540 name: api:latest
541 endpoints:
542 - name: http
543 protocol: http
544 port: 8080
545 tunnel:
546 enabled: true
547 remote_port: 8080
548"#;
549 let result = from_yaml_str(yaml);
550 assert!(
551 result.is_ok(),
552 "Valid endpoint tunnel should pass: {:?}",
553 result
554 );
555 }
556
557 #[test]
558 fn test_valid_top_level_tunnel() {
559 let yaml = r#"
560version: v1
561deployment: my-app
562services:
563 api:
564 image:
565 name: api:latest
566tunnels:
567 db-access:
568 from: api-node
569 to: db-node
570 local_port: 5432
571 remote_port: 5432
572"#;
573 let result = from_yaml_str(yaml);
574 assert!(
575 result.is_ok(),
576 "Valid top-level tunnel should pass: {:?}",
577 result
578 );
579 let spec = result.unwrap();
580 assert!(spec.tunnels.contains_key("db-access"));
581 }
582
583 #[test]
584 fn test_invalid_tunnel_ttl_rejected() {
585 let yaml = r#"
586version: v1
587deployment: my-app
588services:
589 api:
590 image:
591 name: api:latest
592 endpoints:
593 - name: http
594 protocol: http
595 port: 8080
596 tunnel:
597 enabled: true
598 access:
599 enabled: true
600 max_ttl: invalid
601"#;
602 let result = from_yaml_str(yaml);
603 assert!(result.is_err());
604 let err = result.unwrap_err();
605 let err_str = err.to_string();
606 assert!(
607 err_str.contains("ttl") || err_str.contains("TTL") || err_str.contains("invalid"),
608 "Error should mention invalid TTL: {}",
609 err_str
610 );
611 }
612
613 #[test]
614 fn test_invalid_tunnel_local_port_zero_rejected() {
615 let yaml = r#"
616version: v1
617deployment: my-app
618services: {}
619tunnels:
620 bad-tunnel:
621 from: node-a
622 to: node-b
623 local_port: 0
624 remote_port: 8080
625"#;
626 let result = from_yaml_str(yaml);
627 assert!(result.is_err());
628 let err = result.unwrap_err();
629 let err_str = err.to_string();
630 assert!(
631 err_str.contains("port") || err_str.contains("local"),
632 "Error should mention invalid port: {}",
633 err_str
634 );
635 }
636
637 #[test]
638 fn test_invalid_tunnel_remote_port_zero_rejected() {
639 let yaml = r#"
640version: v1
641deployment: my-app
642services: {}
643tunnels:
644 bad-tunnel:
645 from: node-a
646 to: node-b
647 local_port: 8080
648 remote_port: 0
649"#;
650 let result = from_yaml_str(yaml);
651 assert!(result.is_err());
652 let err = result.unwrap_err();
653 let err_str = err.to_string();
654 assert!(
655 err_str.contains("port") || err_str.contains("remote"),
656 "Error should mention invalid port: {}",
657 err_str
658 );
659 }
660
661 #[test]
662 fn test_valid_tunnel_with_access_config() {
663 let yaml = r#"
664version: v1
665deployment: my-app
666services:
667 api:
668 image:
669 name: api:latest
670 endpoints:
671 - name: http
672 protocol: http
673 port: 8080
674 tunnel:
675 enabled: true
676 remote_port: 0
677 access:
678 enabled: true
679 max_ttl: 4h
680 audit: true
681"#;
682 let result = from_yaml_str(yaml);
683 assert!(
684 result.is_ok(),
685 "Valid tunnel with access config should pass: {:?}",
686 result
687 );
688 let spec = result.unwrap();
689 let tunnel = spec.services["api"].endpoints[0].tunnel.as_ref().unwrap();
690 let access = tunnel.access.as_ref().unwrap();
691 assert!(access.enabled);
692 assert_eq!(access.max_ttl, Some("4h".to_string()));
693 assert!(access.audit);
694 }
695}