Skip to main content

zlayer_spec/
lib.rs

1//! ZLayer V1 Service Specification
2//!
3//! This crate provides types for parsing and validating ZLayer deployment specifications.
4
5mod error;
6mod types;
7mod validate;
8
9pub use error::*;
10pub use types::*;
11pub use validate::*;
12
13use validator::Validate;
14
15/// Parse a deployment spec from YAML string
16pub fn from_yaml_str(yaml: &str) -> Result<DeploymentSpec, SpecError> {
17    let spec: DeploymentSpec = serde_yaml::from_str(yaml)?;
18
19    // Run validator crate validation
20    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    // Cross-field validation
30    validate_dependencies(&spec)?;
31    validate_unique_service_endpoints(&spec)?;
32    validate_cron_schedules(&spec)?;
33
34    Ok(spec)
35}
36
37/// Parse a deployment spec from YAML file
38pub 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    // =========================================================================
66    // Integration tests for validation (B.12)
67    // =========================================================================
68
69    #[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    // =========================================================================
348    // Cron schedule validation tests (Feature 4, Phase 1)
349    // =========================================================================
350
351    #[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}