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