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_yaml::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    validate_wasm_configs(&spec)?;
39
40    Ok(spec)
41}
42
43/// Parse a deployment spec from YAML file
44///
45/// # Errors
46///
47/// Returns `SpecError` if the file cannot be read, or parsing/validation fails.
48pub fn from_yaml_file(path: &std::path::Path) -> Result<DeploymentSpec, SpecError> {
49    let content = std::fs::read_to_string(path)?;
50    from_yaml_str(&content)
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn test_parse_from_yaml_str() {
59        let yaml = r"
60version: v1
61deployment: test
62services:
63  hello:
64    rtype: service
65    image:
66      name: hello-world:latest
67";
68        let result = from_yaml_str(yaml);
69        assert!(result.is_ok());
70        let spec = result.unwrap();
71        assert_eq!(spec.version, "v1");
72        assert_eq!(spec.deployment, "test");
73    }
74
75    // =========================================================================
76    // Integration tests for validation (B.12)
77    // =========================================================================
78
79    #[test]
80    fn test_invalid_version_rejected() {
81        let yaml = r"
82version: v2
83deployment: my-app
84services:
85  hello:
86    image:
87      name: hello-world:latest
88";
89        let result = from_yaml_str(yaml);
90        assert!(result.is_err());
91        let err = result.unwrap_err();
92        let err_str = err.to_string();
93        assert!(
94            err_str.contains("version") || err_str.contains("v1"),
95            "Error should mention version: {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: {err_str}",
116        );
117    }
118
119    #[test]
120    fn test_invalid_port_zero_rejected() {
121        let yaml = r"
122version: v1
123deployment: my-app
124services:
125  hello:
126    image:
127      name: hello-world:latest
128    endpoints:
129      - name: http
130        protocol: http
131        port: 0
132";
133        let result = from_yaml_str(yaml);
134        assert!(result.is_err());
135        let err = result.unwrap_err();
136        let err_str = err.to_string();
137        assert!(
138            err_str.contains("port"),
139            "Error should mention port: {err_str}",
140        );
141    }
142
143    #[test]
144    fn test_unknown_dependency_rejected() {
145        let yaml = r"
146version: v1
147deployment: my-app
148services:
149  api:
150    image:
151      name: api:latest
152    depends:
153      - service: database
154";
155        let result = from_yaml_str(yaml);
156        assert!(result.is_err());
157        let err = result.unwrap_err();
158        let err_str = err.to_string();
159        assert!(
160            err_str.contains("database") || err_str.contains("unknown"),
161            "Error should mention unknown dependency: {err_str}",
162        );
163    }
164
165    #[test]
166    fn test_duplicate_endpoint_names_rejected() {
167        let yaml = r"
168version: v1
169deployment: my-app
170services:
171  api:
172    image:
173      name: api:latest
174    endpoints:
175      - name: http
176        protocol: http
177        port: 8080
178      - name: http
179        protocol: https
180        port: 8443
181";
182        let result = from_yaml_str(yaml);
183        assert!(result.is_err());
184        let err = result.unwrap_err();
185        let err_str = err.to_string();
186        assert!(
187            err_str.contains("http") || err_str.contains("duplicate"),
188            "Error should mention duplicate endpoint: {err_str}",
189        );
190    }
191
192    #[test]
193    fn test_invalid_scale_range_min_gt_max_rejected() {
194        let yaml = r"
195version: v1
196deployment: my-app
197services:
198  api:
199    image:
200      name: api:latest
201    scale:
202      mode: adaptive
203      min: 10
204      max: 5
205";
206        let result = from_yaml_str(yaml);
207        assert!(result.is_err());
208        let err = result.unwrap_err();
209        let err_str = err.to_string();
210        assert!(
211            err_str.contains("scale") || err_str.contains("min") || err_str.contains("max"),
212            "Error should mention scale range: {err_str}",
213        );
214    }
215
216    #[test]
217    fn test_invalid_cpu_zero_rejected() {
218        let yaml = r"
219version: v1
220deployment: my-app
221services:
222  api:
223    image:
224      name: api:latest
225    resources:
226      cpu: 0
227";
228        let result = from_yaml_str(yaml);
229        assert!(result.is_err());
230        let err = result.unwrap_err();
231        let err_str = err.to_string();
232        assert!(
233            err_str.contains("cpu") || err_str.contains("CPU"),
234            "Error should mention CPU: {err_str}",
235        );
236    }
237
238    #[test]
239    fn test_invalid_memory_format_rejected() {
240        let yaml = r"
241version: v1
242deployment: my-app
243services:
244  api:
245    image:
246      name: api:latest
247    resources:
248      memory: 512MB
249";
250        let result = from_yaml_str(yaml);
251        assert!(result.is_err());
252        let err = result.unwrap_err();
253        let err_str = err.to_string();
254        assert!(
255            err_str.contains("memory"),
256            "Error should mention memory format: {err_str}",
257        );
258    }
259
260    #[test]
261    fn test_empty_image_name_rejected() {
262        let yaml = r#"
263version: v1
264deployment: my-app
265services:
266  api:
267    image:
268      name: ""
269"#;
270        let result = from_yaml_str(yaml);
271        assert!(result.is_err());
272        let err = result.unwrap_err();
273        let err_str = err.to_string();
274        assert!(
275            err_str.contains("image") || err_str.contains("empty"),
276            "Error should mention empty image name: {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: {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: {err_str}",
412        );
413    }
414
415    #[test]
416    fn test_job_with_schedule_rejected() {
417        let yaml = r#"
418version: v1
419deployment: my-app
420services:
421  backup:
422    rtype: job
423    image:
424      name: backup:latest
425    schedule: "0 0 0 * * * *"
426"#;
427        let result = from_yaml_str(yaml);
428        assert!(result.is_err());
429        let err = result.unwrap_err();
430        let err_str = err.to_string();
431        assert!(
432            err_str.contains("schedule") || err_str.contains("cron"),
433            "Error should mention schedule/cron mismatch: {err_str}",
434        );
435    }
436
437    #[test]
438    fn test_invalid_cron_expression_rejected() {
439        let yaml = r#"
440version: v1
441deployment: my-app
442services:
443  cleanup:
444    rtype: cron
445    image:
446      name: cleanup:latest
447    schedule: "not a valid cron"
448"#;
449        let result = from_yaml_str(yaml);
450        assert!(result.is_err());
451        let err = result.unwrap_err();
452        let err_str = err.to_string();
453        assert!(
454            err_str.contains("cron") || err_str.contains("schedule") || err_str.contains("invalid"),
455            "Error should mention invalid cron expression: {err_str}",
456        );
457    }
458
459    #[test]
460    fn test_valid_extended_cron_expression() {
461        let yaml = r#"
462version: v1
463deployment: my-app
464services:
465  cleanup:
466    rtype: cron
467    image:
468      name: cleanup:latest
469    schedule: "0 30 2 * * * *"
470"#;
471        let result = from_yaml_str(yaml);
472        assert!(
473            result.is_ok(),
474            "Extended cron expression should be valid: {result:?}"
475        );
476    }
477
478    #[test]
479    fn test_mixed_service_types_valid() {
480        let yaml = r#"
481version: v1
482deployment: my-app
483services:
484  api:
485    rtype: service
486    image:
487      name: api:latest
488    endpoints:
489      - name: http
490        protocol: http
491        port: 8080
492  backup:
493    rtype: job
494    image:
495      name: backup:latest
496  cleanup:
497    rtype: cron
498    image:
499      name: cleanup:latest
500    schedule: "0 0 0 * * * *"
501"#;
502        let result = from_yaml_str(yaml);
503        assert!(
504            result.is_ok(),
505            "Mixed service types should be valid: {result:?}"
506        );
507        let spec = result.unwrap();
508        assert_eq!(spec.services.len(), 3);
509        assert_eq!(spec.services["api"].rtype, ResourceType::Service);
510        assert_eq!(spec.services["backup"].rtype, ResourceType::Job);
511        assert_eq!(spec.services["cleanup"].rtype, ResourceType::Cron);
512    }
513
514    // =========================================================================
515    // Tunnel integration tests
516    // =========================================================================
517
518    #[test]
519    fn test_valid_endpoint_tunnel() {
520        let yaml = r"
521version: v1
522deployment: my-app
523services:
524  api:
525    image:
526      name: api:latest
527    endpoints:
528      - name: http
529        protocol: http
530        port: 8080
531        tunnel:
532          enabled: true
533          remote_port: 8080
534";
535        let result = from_yaml_str(yaml);
536        assert!(
537            result.is_ok(),
538            "Valid endpoint tunnel should pass: {result:?}"
539        );
540    }
541
542    #[test]
543    fn test_valid_top_level_tunnel() {
544        let yaml = r"
545version: v1
546deployment: my-app
547services:
548  api:
549    image:
550      name: api:latest
551tunnels:
552  db-access:
553    from: api-node
554    to: db-node
555    local_port: 5432
556    remote_port: 5432
557";
558        let result = from_yaml_str(yaml);
559        assert!(
560            result.is_ok(),
561            "Valid top-level tunnel should pass: {result:?}"
562        );
563        let spec = result.unwrap();
564        assert!(spec.tunnels.contains_key("db-access"));
565    }
566
567    #[test]
568    fn test_invalid_tunnel_ttl_rejected() {
569        let yaml = r"
570version: v1
571deployment: my-app
572services:
573  api:
574    image:
575      name: api:latest
576    endpoints:
577      - name: http
578        protocol: http
579        port: 8080
580        tunnel:
581          enabled: true
582          access:
583            enabled: true
584            max_ttl: invalid
585";
586        let result = from_yaml_str(yaml);
587        assert!(result.is_err());
588        let err = result.unwrap_err();
589        let err_str = err.to_string();
590        assert!(
591            err_str.contains("ttl") || err_str.contains("TTL") || err_str.contains("invalid"),
592            "Error should mention invalid TTL: {err_str}",
593        );
594    }
595
596    #[test]
597    fn test_invalid_tunnel_local_port_zero_rejected() {
598        let yaml = r"
599version: v1
600deployment: my-app
601services: {}
602tunnels:
603  bad-tunnel:
604    from: node-a
605    to: node-b
606    local_port: 0
607    remote_port: 8080
608";
609        let result = from_yaml_str(yaml);
610        assert!(result.is_err());
611        let err = result.unwrap_err();
612        let err_str = err.to_string();
613        assert!(
614            err_str.contains("port") || err_str.contains("local"),
615            "Error should mention invalid port: {err_str}",
616        );
617    }
618
619    #[test]
620    fn test_invalid_tunnel_remote_port_zero_rejected() {
621        let yaml = r"
622version: v1
623deployment: my-app
624services: {}
625tunnels:
626  bad-tunnel:
627    from: node-a
628    to: node-b
629    local_port: 8080
630    remote_port: 0
631";
632        let result = from_yaml_str(yaml);
633        assert!(result.is_err());
634        let err = result.unwrap_err();
635        let err_str = err.to_string();
636        assert!(
637            err_str.contains("port") || err_str.contains("remote"),
638            "Error should mention invalid port: {err_str}",
639        );
640    }
641
642    #[test]
643    fn test_valid_tunnel_with_access_config() {
644        let yaml = r"
645version: v1
646deployment: my-app
647services:
648  api:
649    image:
650      name: api:latest
651    endpoints:
652      - name: http
653        protocol: http
654        port: 8080
655        tunnel:
656          enabled: true
657          remote_port: 0
658          access:
659            enabled: true
660            max_ttl: 4h
661            audit: true
662";
663        let result = from_yaml_str(yaml);
664        assert!(
665            result.is_ok(),
666            "Valid tunnel with access config should pass: {result:?}"
667        );
668        let spec = result.unwrap();
669        let tunnel = spec.services["api"].endpoints[0].tunnel.as_ref().unwrap();
670        let access = tunnel.access.as_ref().unwrap();
671        assert!(access.enabled);
672        assert_eq!(access.max_ttl, Some("4h".to_string()));
673        assert!(access.audit);
674    }
675
676    // =========================================================================
677    // WASM validation integration tests
678    // =========================================================================
679
680    #[test]
681    fn test_wasm_config_on_non_wasm_type_rejected() {
682        let yaml = r"
683version: v1
684deployment: my-app
685services:
686  api:
687    image:
688      name: api:latest
689    service_type: standard
690    wasm:
691      min_instances: 1
692      max_instances: 4
693";
694        let result = from_yaml_str(yaml);
695        assert!(result.is_err());
696        let err_str = result.unwrap_err().to_string();
697        assert!(
698            err_str.contains("wasm") || err_str.contains("WASM"),
699            "Error should mention wasm config on non-wasm type: {err_str}",
700        );
701    }
702
703    #[test]
704    fn test_wasm_service_without_config_is_ok() {
705        // WASM service type without explicit wasm config is fine (defaults will be used)
706        let yaml = r"
707version: v1
708deployment: my-app
709services:
710  handler:
711    image:
712      name: handler:latest
713    service_type: wasm_http
714";
715        let result = from_yaml_str(yaml);
716        assert!(
717            result.is_ok(),
718            "WASM service without explicit config should pass: {result:?}"
719        );
720    }
721
722    #[test]
723    fn test_wasm_min_instances_gt_max_rejected() {
724        let yaml = r"
725version: v1
726deployment: my-app
727services:
728  handler:
729    image:
730      name: handler:latest
731    service_type: wasm_http
732    wasm:
733      min_instances: 10
734      max_instances: 2
735";
736        let result = from_yaml_str(yaml);
737        assert!(result.is_err());
738        let err_str = result.unwrap_err().to_string();
739        assert!(
740            err_str.contains("min_instances") || err_str.contains("max_instances"),
741            "Error should mention instance range: {err_str}",
742        );
743    }
744
745    #[test]
746    fn test_wasm_valid_instance_range() {
747        let yaml = r"
748version: v1
749deployment: my-app
750services:
751  handler:
752    image:
753      name: handler:latest
754    service_type: wasm_http
755    wasm:
756      min_instances: 2
757      max_instances: 10
758";
759        let result = from_yaml_str(yaml);
760        assert!(
761            result.is_ok(),
762            "Valid WASM instance range should pass: {result:?}"
763        );
764    }
765
766    #[test]
767    fn test_wasm_invalid_max_memory_format() {
768        let yaml = r#"
769version: v1
770deployment: my-app
771services:
772  handler:
773    image:
774      name: handler:latest
775    service_type: wasm_http
776    wasm:
777      max_memory: "512MB"
778"#;
779        let result = from_yaml_str(yaml);
780        assert!(result.is_err());
781        let err_str = result.unwrap_err().to_string();
782        assert!(
783            err_str.contains("memory") || err_str.contains("512MB"),
784            "Error should mention invalid memory format: {err_str}",
785        );
786    }
787
788    #[test]
789    fn test_wasm_valid_max_memory_format() {
790        let yaml = r#"
791version: v1
792deployment: my-app
793services:
794  handler:
795    image:
796      name: handler:latest
797    service_type: wasm_http
798    wasm:
799      max_memory: "256Mi"
800"#;
801        let result = from_yaml_str(yaml);
802        assert!(
803            result.is_ok(),
804            "Valid WASM max_memory should pass: {result:?}"
805        );
806    }
807
808    #[test]
809    fn test_wasm_capability_escalation_rejected() {
810        // WasmTransformer defaults: secrets=false, so requesting secrets=true should fail
811        let yaml = r"
812version: v1
813deployment: my-app
814services:
815  transform:
816    image:
817      name: transform:latest
818    service_type: wasm_transformer
819    wasm:
820      capabilities:
821        secrets: true
822";
823        let result = from_yaml_str(yaml);
824        assert!(result.is_err());
825        let err_str = result.unwrap_err().to_string();
826        assert!(
827            err_str.contains("secrets") || err_str.contains("capability"),
828            "Error should mention capability escalation: {err_str}",
829        );
830    }
831
832    #[test]
833    fn test_wasm_capability_restriction_allowed() {
834        // WasmHttp defaults: config=true, keyvalue=true, logging=true, http_client=true
835        // and metrics=false, secrets=false, cli=false, filesystem=false, sockets=false.
836        // Restricting config=false while keeping all others at or below defaults should pass.
837        let yaml = r"
838version: v1
839deployment: my-app
840services:
841  handler:
842    image:
843      name: handler:latest
844    service_type: wasm_http
845    wasm:
846      capabilities:
847        config: false
848        keyvalue: true
849        logging: true
850        secrets: false
851        metrics: false
852        http_client: true
853        cli: false
854        filesystem: false
855        sockets: false
856";
857        let result = from_yaml_str(yaml);
858        assert!(
859            result.is_ok(),
860            "Restricting a default capability should pass: {result:?}"
861        );
862    }
863
864    #[test]
865    fn test_wasm_http_with_tcp_only_endpoints_rejected() {
866        // WasmHttp with endpoints but none are HTTP should fail
867        let yaml = r"
868version: v1
869deployment: my-app
870services:
871  handler:
872    image:
873      name: handler:latest
874    service_type: wasm_http
875    wasm:
876      min_instances: 1
877    endpoints:
878      - name: raw
879        protocol: tcp
880        port: 9090
881";
882        let result = from_yaml_str(yaml);
883        assert!(result.is_err());
884        let err_str = result.unwrap_err().to_string();
885        assert!(
886            err_str.contains("HTTP") || err_str.contains("http") || err_str.contains("endpoint"),
887            "Error should mention missing HTTP endpoint: {err_str}",
888        );
889    }
890
891    #[test]
892    fn test_wasm_http_with_http_endpoint_passes() {
893        let yaml = r"
894version: v1
895deployment: my-app
896services:
897  handler:
898    image:
899      name: handler:latest
900    service_type: wasm_http
901    wasm:
902      min_instances: 1
903    endpoints:
904      - name: web
905        protocol: http
906        port: 8080
907";
908        let result = from_yaml_str(yaml);
909        assert!(
910            result.is_ok(),
911            "WasmHttp with HTTP endpoint should pass: {result:?}"
912        );
913    }
914
915    #[test]
916    fn test_wasm_http_with_no_endpoints_passes() {
917        // WasmHttp with no endpoints at all is fine (endpoints are optional)
918        let yaml = r"
919version: v1
920deployment: my-app
921services:
922  handler:
923    image:
924      name: handler:latest
925    service_type: wasm_http
926    wasm:
927      min_instances: 1
928";
929        let result = from_yaml_str(yaml);
930        assert!(
931            result.is_ok(),
932            "WasmHttp with no endpoints should pass: {result:?}"
933        );
934    }
935
936    #[test]
937    fn test_wasm_preopen_empty_source_rejected() {
938        let yaml = r#"
939version: v1
940deployment: my-app
941services:
942  handler:
943    image:
944      name: handler:latest
945    service_type: wasm_plugin
946    wasm:
947      preopens:
948        - source: ""
949          target: /data
950"#;
951        let result = from_yaml_str(yaml);
952        assert!(result.is_err());
953        let err_str = result.unwrap_err().to_string();
954        assert!(
955            err_str.contains("preopen") || err_str.contains("source") || err_str.contains("empty"),
956            "Error should mention empty preopen source: {err_str}",
957        );
958    }
959
960    #[test]
961    fn test_wasm_preopen_empty_target_rejected() {
962        let yaml = r#"
963version: v1
964deployment: my-app
965services:
966  handler:
967    image:
968      name: handler:latest
969    service_type: wasm_plugin
970    wasm:
971      preopens:
972        - source: /host/data
973          target: ""
974"#;
975        let result = from_yaml_str(yaml);
976        assert!(result.is_err());
977        let err_str = result.unwrap_err().to_string();
978        assert!(
979            err_str.contains("preopen") || err_str.contains("target") || err_str.contains("empty"),
980            "Error should mention empty preopen target: {err_str}",
981        );
982    }
983
984    #[test]
985    fn test_wasm_valid_preopen_passes() {
986        let yaml = r"
987version: v1
988deployment: my-app
989services:
990  handler:
991    image:
992      name: handler:latest
993    service_type: wasm_plugin
994    wasm:
995      preopens:
996        - source: /host/data
997          target: /data
998          readonly: true
999";
1000        let result = from_yaml_str(yaml);
1001        assert!(result.is_ok(), "Valid WASM preopen should pass: {result:?}");
1002    }
1003
1004    #[test]
1005    fn test_wasm_plugin_can_use_all_capabilities() {
1006        // WasmPlugin has all capabilities except sockets as defaults
1007        let yaml = r"
1008version: v1
1009deployment: my-app
1010services:
1011  plugin:
1012    image:
1013      name: plugin:latest
1014    service_type: wasm_plugin
1015    wasm:
1016      capabilities:
1017        config: true
1018        keyvalue: true
1019        logging: true
1020        secrets: true
1021        metrics: true
1022        http_client: true
1023        cli: true
1024        filesystem: true
1025";
1026        let result = from_yaml_str(yaml);
1027        assert!(
1028            result.is_ok(),
1029            "WasmPlugin with all its default capabilities should pass: {result:?}"
1030        );
1031    }
1032
1033    #[test]
1034    fn test_wasm_plugin_cannot_use_sockets() {
1035        // WasmPlugin defaults: sockets=false
1036        let yaml = r"
1037version: v1
1038deployment: my-app
1039services:
1040  plugin:
1041    image:
1042      name: plugin:latest
1043    service_type: wasm_plugin
1044    wasm:
1045      capabilities:
1046        sockets: true
1047";
1048        let result = from_yaml_str(yaml);
1049        assert!(result.is_err());
1050        let err_str = result.unwrap_err().to_string();
1051        assert!(
1052            err_str.contains("sockets") || err_str.contains("capability"),
1053            "Error should mention sockets capability escalation: {err_str}",
1054        );
1055    }
1056
1057    #[test]
1058    fn test_wasm_equal_min_max_instances_passes() {
1059        let yaml = r"
1060version: v1
1061deployment: my-app
1062services:
1063  handler:
1064    image:
1065      name: handler:latest
1066    service_type: wasm_http
1067    wasm:
1068      min_instances: 5
1069      max_instances: 5
1070";
1071        let result = from_yaml_str(yaml);
1072        assert!(
1073            result.is_ok(),
1074            "Equal min/max instances should pass: {result:?}"
1075        );
1076    }
1077}