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
16///
17/// # Errors
18///
19/// Returns `SpecError` if parsing or validation fails.
20pub fn from_yaml_str(yaml: &str) -> Result<DeploymentSpec, SpecError> {
21    let spec: DeploymentSpec = serde_yml::from_str(yaml)?;
22
23    // Run validator crate validation
24    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    // Cross-field validation
34    validate_dependencies(&spec)?;
35    validate_unique_service_endpoints(&spec)?;
36    validate_cron_schedules(&spec)?;
37    validate_tunnels(&spec)?;
38
39    Ok(spec)
40}
41
42/// Parse a deployment spec from YAML file
43///
44/// # Errors
45///
46/// Returns `SpecError` if the file cannot be read, or parsing/validation fails.
47pub 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    // =========================================================================
75    // Integration tests for validation (B.12)
76    // =========================================================================
77
78    #[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    // =========================================================================
357    // Cron schedule validation tests (Feature 4, Phase 1)
358    // =========================================================================
359
360    #[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    // =========================================================================
529    // Tunnel integration tests
530    // =========================================================================
531
532    #[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}