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    validate_tunnels(&spec)?;
34
35    Ok(spec)
36}
37
38/// Parse a deployment spec from YAML file
39pub 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    // =========================================================================
67    // Integration tests for validation (B.12)
68    // =========================================================================
69
70    #[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    // =========================================================================
349    // Cron schedule validation tests (Feature 4, Phase 1)
350    // =========================================================================
351
352    #[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    // =========================================================================
521    // Tunnel integration tests
522    // =========================================================================
523
524    #[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}