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
34 Ok(spec)
35}
36
37pub fn from_yaml_file(path: &std::path::Path) -> Result<DeploymentSpec, SpecError> {
39 let content = std::fs::read_to_string(path)?;
40 from_yaml_str(&content)
41}
42
43#[cfg(test)]
44mod tests {
45 use super::*;
46
47 #[test]
48 fn test_parse_from_yaml_str() {
49 let yaml = r#"
50version: v1
51deployment: test
52services:
53 hello:
54 rtype: service
55 image:
56 name: hello-world:latest
57"#;
58 let result = from_yaml_str(yaml);
59 assert!(result.is_ok());
60 let spec = result.unwrap();
61 assert_eq!(spec.version, "v1");
62 assert_eq!(spec.deployment, "test");
63 }
64
65 #[test]
70 fn test_invalid_version_rejected() {
71 let yaml = r#"
72version: v2
73deployment: my-app
74services:
75 hello:
76 image:
77 name: hello-world:latest
78"#;
79 let result = from_yaml_str(yaml);
80 assert!(result.is_err());
81 let err = result.unwrap_err();
82 let err_str = err.to_string();
83 assert!(
84 err_str.contains("version") || err_str.contains("v1"),
85 "Error should mention version: {}",
86 err_str
87 );
88 }
89
90 #[test]
91 fn test_empty_deployment_name_rejected() {
92 let yaml = r#"
93version: v1
94deployment: ab
95services:
96 hello:
97 image:
98 name: hello-world:latest
99"#;
100 let result = from_yaml_str(yaml);
101 assert!(result.is_err());
102 let err = result.unwrap_err();
103 let err_str = err.to_string();
104 assert!(
105 err_str.contains("deployment") || err_str.contains("3-63"),
106 "Error should mention deployment name: {}",
107 err_str
108 );
109 }
110
111 #[test]
112 fn test_invalid_port_zero_rejected() {
113 let yaml = r#"
114version: v1
115deployment: my-app
116services:
117 hello:
118 image:
119 name: hello-world:latest
120 endpoints:
121 - name: http
122 protocol: http
123 port: 0
124"#;
125 let result = from_yaml_str(yaml);
126 assert!(result.is_err());
127 let err = result.unwrap_err();
128 let err_str = err.to_string();
129 assert!(
130 err_str.contains("port"),
131 "Error should mention port: {}",
132 err_str
133 );
134 }
135
136 #[test]
137 fn test_unknown_dependency_rejected() {
138 let yaml = r#"
139version: v1
140deployment: my-app
141services:
142 api:
143 image:
144 name: api:latest
145 depends:
146 - service: database
147"#;
148 let result = from_yaml_str(yaml);
149 assert!(result.is_err());
150 let err = result.unwrap_err();
151 let err_str = err.to_string();
152 assert!(
153 err_str.contains("database") || err_str.contains("unknown"),
154 "Error should mention unknown dependency: {}",
155 err_str
156 );
157 }
158
159 #[test]
160 fn test_duplicate_endpoint_names_rejected() {
161 let yaml = r#"
162version: v1
163deployment: my-app
164services:
165 api:
166 image:
167 name: api:latest
168 endpoints:
169 - name: http
170 protocol: http
171 port: 8080
172 - name: http
173 protocol: https
174 port: 8443
175"#;
176 let result = from_yaml_str(yaml);
177 assert!(result.is_err());
178 let err = result.unwrap_err();
179 let err_str = err.to_string();
180 assert!(
181 err_str.contains("http") || err_str.contains("duplicate"),
182 "Error should mention duplicate endpoint: {}",
183 err_str
184 );
185 }
186
187 #[test]
188 fn test_invalid_scale_range_min_gt_max_rejected() {
189 let yaml = r#"
190version: v1
191deployment: my-app
192services:
193 api:
194 image:
195 name: api:latest
196 scale:
197 mode: adaptive
198 min: 10
199 max: 5
200"#;
201 let result = from_yaml_str(yaml);
202 assert!(result.is_err());
203 let err = result.unwrap_err();
204 let err_str = err.to_string();
205 assert!(
206 err_str.contains("scale") || err_str.contains("min") || err_str.contains("max"),
207 "Error should mention scale range: {}",
208 err_str
209 );
210 }
211
212 #[test]
213 fn test_invalid_cpu_zero_rejected() {
214 let yaml = r#"
215version: v1
216deployment: my-app
217services:
218 api:
219 image:
220 name: api:latest
221 resources:
222 cpu: 0
223"#;
224 let result = from_yaml_str(yaml);
225 assert!(result.is_err());
226 let err = result.unwrap_err();
227 let err_str = err.to_string();
228 assert!(
229 err_str.contains("cpu") || err_str.contains("CPU"),
230 "Error should mention CPU: {}",
231 err_str
232 );
233 }
234
235 #[test]
236 fn test_invalid_memory_format_rejected() {
237 let yaml = r#"
238version: v1
239deployment: my-app
240services:
241 api:
242 image:
243 name: api:latest
244 resources:
245 memory: 512MB
246"#;
247 let result = from_yaml_str(yaml);
248 assert!(result.is_err());
249 let err = result.unwrap_err();
250 let err_str = err.to_string();
251 assert!(
252 err_str.contains("memory"),
253 "Error should mention memory format: {}",
254 err_str
255 );
256 }
257
258 #[test]
259 fn test_empty_image_name_rejected() {
260 let yaml = r#"
261version: v1
262deployment: my-app
263services:
264 api:
265 image:
266 name: ""
267"#;
268 let result = from_yaml_str(yaml);
269 assert!(result.is_err());
270 let err = result.unwrap_err();
271 let err_str = err.to_string();
272 assert!(
273 err_str.contains("image") || err_str.contains("empty"),
274 "Error should mention empty image name: {}",
275 err_str
276 );
277 }
278
279 #[test]
280 fn test_valid_spec_passes_validation() {
281 let yaml = r#"
282version: v1
283deployment: my-production-app
284services:
285 api:
286 image:
287 name: ghcr.io/myorg/api:v1.2.3
288 resources:
289 cpu: 0.5
290 memory: 512Mi
291 endpoints:
292 - name: http
293 protocol: http
294 port: 8080
295 expose: public
296 - name: metrics
297 protocol: http
298 port: 9090
299 expose: internal
300 scale:
301 mode: adaptive
302 min: 2
303 max: 10
304 targets:
305 cpu: 70
306 depends:
307 - service: database
308 condition: healthy
309 database:
310 image:
311 name: postgres:15
312 endpoints:
313 - name: postgres
314 protocol: tcp
315 port: 5432
316 scale:
317 mode: fixed
318 replicas: 1
319"#;
320 let result = from_yaml_str(yaml);
321 assert!(result.is_ok(), "Valid spec should pass: {:?}", result);
322 let spec = result.unwrap();
323 assert_eq!(spec.version, "v1");
324 assert_eq!(spec.deployment, "my-production-app");
325 assert_eq!(spec.services.len(), 2);
326 }
327
328 #[test]
329 fn test_valid_dependency_passes() {
330 let yaml = r#"
331version: v1
332deployment: my-app
333services:
334 api:
335 image:
336 name: api:latest
337 depends:
338 - service: database
339 database:
340 image:
341 name: postgres:15
342"#;
343 let result = from_yaml_str(yaml);
344 assert!(result.is_ok(), "Valid dependency should pass: {:?}", result);
345 }
346
347 #[test]
352 fn test_valid_cron_job_with_schedule() {
353 let yaml = r#"
354version: v1
355deployment: my-app
356services:
357 cleanup:
358 rtype: cron
359 image:
360 name: cleanup:latest
361 schedule: "0 0 0 * * * *"
362"#;
363 let result = from_yaml_str(yaml);
364 assert!(result.is_ok(), "Valid cron job should pass: {:?}", result);
365 let spec = result.unwrap();
366 let cleanup = spec.services.get("cleanup").unwrap();
367 assert_eq!(cleanup.rtype, ResourceType::Cron);
368 assert_eq!(cleanup.schedule, Some("0 0 0 * * * *".to_string()));
369 }
370
371 #[test]
372 fn test_cron_without_schedule_rejected() {
373 let yaml = r#"
374version: v1
375deployment: my-app
376services:
377 cleanup:
378 rtype: cron
379 image:
380 name: cleanup:latest
381"#;
382 let result = from_yaml_str(yaml);
383 assert!(result.is_err());
384 let err = result.unwrap_err();
385 let err_str = err.to_string();
386 assert!(
387 err_str.contains("schedule") || err_str.contains("cron"),
388 "Error should mention missing schedule: {}",
389 err_str
390 );
391 }
392
393 #[test]
394 fn test_service_with_schedule_rejected() {
395 let yaml = r#"
396version: v1
397deployment: my-app
398services:
399 api:
400 rtype: service
401 image:
402 name: api:latest
403 schedule: "0 0 0 * * * *"
404"#;
405 let result = from_yaml_str(yaml);
406 assert!(result.is_err());
407 let err = result.unwrap_err();
408 let err_str = err.to_string();
409 assert!(
410 err_str.contains("schedule") || err_str.contains("cron"),
411 "Error should mention schedule/cron mismatch: {}",
412 err_str
413 );
414 }
415
416 #[test]
417 fn test_job_with_schedule_rejected() {
418 let yaml = r#"
419version: v1
420deployment: my-app
421services:
422 backup:
423 rtype: job
424 image:
425 name: backup:latest
426 schedule: "0 0 0 * * * *"
427"#;
428 let result = from_yaml_str(yaml);
429 assert!(result.is_err());
430 let err = result.unwrap_err();
431 let err_str = err.to_string();
432 assert!(
433 err_str.contains("schedule") || err_str.contains("cron"),
434 "Error should mention schedule/cron mismatch: {}",
435 err_str
436 );
437 }
438
439 #[test]
440 fn test_invalid_cron_expression_rejected() {
441 let yaml = r#"
442version: v1
443deployment: my-app
444services:
445 cleanup:
446 rtype: cron
447 image:
448 name: cleanup:latest
449 schedule: "not a valid cron"
450"#;
451 let result = from_yaml_str(yaml);
452 assert!(result.is_err());
453 let err = result.unwrap_err();
454 let err_str = err.to_string();
455 assert!(
456 err_str.contains("cron") || err_str.contains("schedule") || err_str.contains("invalid"),
457 "Error should mention invalid cron expression: {}",
458 err_str
459 );
460 }
461
462 #[test]
463 fn test_valid_extended_cron_expression() {
464 let yaml = r#"
465version: v1
466deployment: my-app
467services:
468 cleanup:
469 rtype: cron
470 image:
471 name: cleanup:latest
472 schedule: "0 30 2 * * * *"
473"#;
474 let result = from_yaml_str(yaml);
475 assert!(
476 result.is_ok(),
477 "Extended cron expression should be valid: {:?}",
478 result
479 );
480 }
481
482 #[test]
483 fn test_mixed_service_types_valid() {
484 let yaml = r#"
485version: v1
486deployment: my-app
487services:
488 api:
489 rtype: service
490 image:
491 name: api:latest
492 endpoints:
493 - name: http
494 protocol: http
495 port: 8080
496 backup:
497 rtype: job
498 image:
499 name: backup:latest
500 cleanup:
501 rtype: cron
502 image:
503 name: cleanup:latest
504 schedule: "0 0 0 * * * *"
505"#;
506 let result = from_yaml_str(yaml);
507 assert!(
508 result.is_ok(),
509 "Mixed service types should be valid: {:?}",
510 result
511 );
512 let spec = result.unwrap();
513 assert_eq!(spec.services.len(), 3);
514 assert_eq!(spec.services["api"].rtype, ResourceType::Service);
515 assert_eq!(spec.services["backup"].rtype, ResourceType::Job);
516 assert_eq!(spec.services["cleanup"].rtype, ResourceType::Cron);
517 }
518}