1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(untagged)]
39pub enum ZBuildContext {
40 Short(String),
42 Full {
44 #[serde(alias = "context", default)]
47 workdir: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 file: Option<String>,
52 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
54 args: HashMap<String, String>,
55 },
56}
57
58impl ZBuildContext {
59 #[must_use]
61 pub fn context_dir(&self, base: &Path) -> PathBuf {
62 match self {
63 Self::Short(path) => base.join(path),
64 Self::Full { workdir, .. } => match workdir {
65 Some(dir) => base.join(dir),
66 None => base.to_path_buf(),
67 },
68 }
69 }
70
71 #[must_use]
73 pub fn file(&self) -> Option<&str> {
74 match self {
75 Self::Short(_) => None,
76 Self::Full { file, .. } => file.as_deref(),
77 }
78 }
79
80 #[must_use]
82 pub fn args(&self) -> HashMap<String, String> {
83 match self {
84 Self::Short(_) => HashMap::new(),
85 Self::Full { args, .. } => args.clone(),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct ZImage {
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub version: Option<String>,
110
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub runtime: Option<String>,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub base: Option<String>,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub build: Option<ZBuildContext>,
126
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub steps: Vec<ZStep>,
130
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub platform: Option<String>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub stages: Option<IndexMap<String, ZStage>>,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub wasm: Option<ZWasmConfig>,
145
146 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
149 pub env: HashMap<String, String>,
150
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub workdir: Option<String>,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub expose: Option<ZExpose>,
158
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub cmd: Option<ZCommand>,
162
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub entrypoint: Option<ZCommand>,
166
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub user: Option<String>,
170
171 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
173 pub labels: HashMap<String, String>,
174
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub volumes: Vec<String>,
178
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub healthcheck: Option<ZHealthcheck>,
182
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub stopsignal: Option<String>,
186
187 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
189 pub args: HashMap<String, String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct ZStage {
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub base: Option<String>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub build: Option<ZBuildContext>,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub platform: Option<String>,
213
214 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
216 pub args: HashMap<String, String>,
217
218 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
220 pub env: HashMap<String, String>,
221
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub workdir: Option<String>,
225
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
228 pub steps: Vec<ZStep>,
229
230 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
232 pub labels: HashMap<String, String>,
233
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub expose: Option<ZExpose>,
237
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub user: Option<String>,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub entrypoint: Option<ZCommand>,
245
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub cmd: Option<ZCommand>,
249
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub volumes: Vec<String>,
253
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub healthcheck: Option<ZHealthcheck>,
257
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub stopsignal: Option<String>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct ZStep {
275 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub run: Option<ZCommand>,
279
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub copy: Option<ZCopySources>,
283
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub add: Option<ZCopySources>,
287
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub env: Option<HashMap<String, String>>,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub workdir: Option<String>,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub user: Option<String>,
299
300 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub to: Option<String>,
304
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub from: Option<String>,
308
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub owner: Option<String>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub chmod: Option<String>,
316
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub cache: Vec<ZCacheMount>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct ZCacheMount {
332 pub target: String,
334
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub id: Option<String>,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub sharing: Option<String>,
342
343 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
345 pub readonly: bool,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(untagged)]
368pub enum ZCommand {
369 Shell(String),
371 Exec(Vec<String>),
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(untagged)]
394pub enum ZCopySources {
395 Single(String),
397 Multiple(Vec<String>),
399}
400
401impl ZCopySources {
402 #[must_use]
404 pub fn to_vec(&self) -> Vec<String> {
405 match self {
406 Self::Single(s) => vec![s.clone()],
407 Self::Multiple(v) => v.clone(),
408 }
409 }
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(untagged)]
433pub enum ZExpose {
434 Single(u16),
436 Multiple(Vec<ZPortSpec>),
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
455#[serde(untagged)]
456pub enum ZPortSpec {
457 Number(u16),
459 WithProtocol(String),
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
480#[serde(deny_unknown_fields)]
481pub struct ZHealthcheck {
482 pub cmd: ZCommand,
484
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub interval: Option<String>,
488
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub timeout: Option<String>,
492
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub start_period: Option<String>,
496
497 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub retries: Option<u32>,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
529#[serde(deny_unknown_fields)]
530pub struct ZWasmConfig {
531 #[serde(default = "default_wasm_target")]
533 pub target: String,
534
535 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
537 pub optimize: bool,
538
539 #[serde(
541 default = "default_wasm_opt_level",
542 skip_serializing_if = "Option::is_none"
543 )]
544 pub opt_level: Option<String>,
545
546 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub language: Option<String>,
549
550 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub wit: Option<String>,
553
554 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub world: Option<String>,
558
559 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub output: Option<String>,
562
563 #[serde(default, skip_serializing_if = "Vec::is_empty")]
565 pub features: Vec<String>,
566
567 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
569 pub build_args: HashMap<String, String>,
570
571 #[serde(default, skip_serializing_if = "Vec::is_empty")]
573 pub pre_build: Vec<ZCommand>,
574
575 #[serde(default, skip_serializing_if = "Vec::is_empty")]
577 pub post_build: Vec<ZCommand>,
578
579 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub adapter: Option<String>,
582}
583
584fn default_wasm_target() -> String {
590 "preview2".to_string()
591}
592
593#[allow(clippy::unnecessary_wraps)]
595fn default_wasm_opt_level() -> Option<String> {
596 Some("Oz".to_string())
597}
598
599#[allow(clippy::trivially_copy_pass_by_ref)]
601fn is_false(v: &bool) -> bool {
602 !v
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn test_runtime_mode_deserialize() {
611 let yaml = r#"
612runtime: node22
613cmd: "node server.js"
614"#;
615 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
616 assert_eq!(img.runtime.as_deref(), Some("node22"));
617 assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
618 }
619
620 #[test]
621 fn test_single_stage_deserialize() {
622 let yaml = r#"
623base: "alpine:3.19"
624steps:
625 - run: "apk add --no-cache curl"
626 - copy: "app.sh"
627 to: "/usr/local/bin/app.sh"
628 chmod: "755"
629 - workdir: "/app"
630env:
631 NODE_ENV: production
632expose: 8080
633cmd: ["./app.sh"]
634"#;
635 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
636 assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
637 assert_eq!(img.steps.len(), 3);
638 assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
639 assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
640 assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
641 }
642
643 #[test]
644 fn test_multi_stage_deserialize() {
645 let yaml = r#"
646stages:
647 builder:
648 base: "node:22-alpine"
649 workdir: "/src"
650 steps:
651 - copy: ["package.json", "package-lock.json"]
652 to: "./"
653 - run: "npm ci"
654 - copy: "."
655 to: "."
656 - run: "npm run build"
657 runtime:
658 base: "node:22-alpine"
659 workdir: "/app"
660 steps:
661 - copy: "dist"
662 from: builder
663 to: "/app"
664 cmd: ["node", "dist/index.js"]
665expose: 3000
666"#;
667 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
668 let stages = img.stages.as_ref().unwrap();
669 assert_eq!(stages.len(), 2);
670
671 let keys: Vec<&String> = stages.keys().collect();
673 assert_eq!(keys, vec!["builder", "runtime"]);
674
675 let builder = &stages["builder"];
676 assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
677 assert_eq!(builder.steps.len(), 4);
678
679 let runtime = &stages["runtime"];
680 assert_eq!(runtime.steps.len(), 1);
681 assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
682 }
683
684 #[test]
685 fn test_wasm_mode_deserialize() {
686 let yaml = r#"
687wasm:
688 target: preview2
689 optimize: true
690 language: rust
691 wit: "./wit"
692 output: "./output.wasm"
693"#;
694 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
695 let wasm = img.wasm.as_ref().unwrap();
696 assert_eq!(wasm.target, "preview2");
697 assert!(wasm.optimize);
698 assert_eq!(wasm.language.as_deref(), Some("rust"));
699 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
700 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
701 }
702
703 #[test]
704 fn test_wasm_defaults() {
705 let yaml = r"
706wasm: {}
707";
708 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
709 let wasm = img.wasm.as_ref().unwrap();
710 assert_eq!(wasm.target, "preview2");
711 assert!(!wasm.optimize);
712 assert!(wasm.language.is_none());
713 assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
714 assert!(wasm.world.is_none());
715 assert!(wasm.features.is_empty());
716 assert!(wasm.build_args.is_empty());
717 assert!(wasm.pre_build.is_empty());
718 assert!(wasm.post_build.is_empty());
719 assert!(wasm.adapter.is_none());
720 }
721
722 #[test]
723 fn test_wasm_full_config() {
724 let yaml = r#"
725wasm:
726 target: "preview2"
727 optimize: true
728 opt_level: "O3"
729 language: "rust"
730 world: "zlayer-http-handler"
731 wit: "./wit"
732 output: "./output.wasm"
733 features:
734 - json
735 - metrics
736 build_args:
737 CARGO_PROFILE_RELEASE_LTO: "true"
738 RUSTFLAGS: "-C target-feature=+simd128"
739 pre_build:
740 - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
741 post_build:
742 - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
743 adapter: "./wasi_snapshot_preview1.reactor.wasm"
744"#;
745 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
746 let wasm = img.wasm.as_ref().unwrap();
747 assert_eq!(wasm.target, "preview2");
748 assert!(wasm.optimize);
749 assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
750 assert_eq!(wasm.language.as_deref(), Some("rust"));
751 assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
752 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
753 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
754 assert_eq!(wasm.features, vec!["json", "metrics"]);
755 assert_eq!(
756 wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
757 "true"
758 );
759 assert_eq!(
760 wasm.build_args.get("RUSTFLAGS").unwrap(),
761 "-C target-feature=+simd128"
762 );
763 assert_eq!(wasm.pre_build.len(), 1);
764 assert_eq!(wasm.post_build.len(), 1);
765 assert_eq!(
766 wasm.adapter.as_deref(),
767 Some("./wasi_snapshot_preview1.reactor.wasm")
768 );
769 }
770
771 #[test]
772 fn test_zcommand_shell() {
773 let yaml = r#""echo hello""#;
774 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
775 assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
776 }
777
778 #[test]
779 fn test_zcommand_exec() {
780 let yaml = r#"["echo", "hello"]"#;
781 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
782 assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
783 }
784
785 #[test]
786 fn test_zcopy_sources_single() {
787 let yaml = r#""package.json""#;
788 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
789 assert_eq!(src.to_vec(), vec!["package.json"]);
790 }
791
792 #[test]
793 fn test_zcopy_sources_multiple() {
794 let yaml = r#"["package.json", "tsconfig.json"]"#;
795 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
796 assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
797 }
798
799 #[test]
800 fn test_zexpose_single() {
801 let yaml = "8080";
802 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
803 assert!(matches!(exp, ZExpose::Single(8080)));
804 }
805
806 #[test]
807 fn test_zexpose_multiple() {
808 let yaml = r#"
809- 8080
810- "9090/udp"
811"#;
812 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
813 if let ZExpose::Multiple(ports) = exp {
814 assert_eq!(ports.len(), 2);
815 assert!(matches!(ports[0], ZPortSpec::Number(8080)));
816 assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
817 } else {
818 panic!("Expected ZExpose::Multiple");
819 }
820 }
821
822 #[test]
823 fn test_healthcheck_deserialize() {
824 let yaml = r#"
825cmd: "curl -f http://localhost/ || exit 1"
826interval: "30s"
827timeout: "10s"
828start_period: "5s"
829retries: 3
830"#;
831 let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
832 assert!(matches!(hc.cmd, ZCommand::Shell(_)));
833 assert_eq!(hc.interval.as_deref(), Some("30s"));
834 assert_eq!(hc.timeout.as_deref(), Some("10s"));
835 assert_eq!(hc.start_period.as_deref(), Some("5s"));
836 assert_eq!(hc.retries, Some(3));
837 }
838
839 #[test]
840 fn test_cache_mount_deserialize() {
841 let yaml = r"
842target: /var/cache/apt
843id: apt-cache
844sharing: shared
845readonly: false
846";
847 let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
848 assert_eq!(cm.target, "/var/cache/apt");
849 assert_eq!(cm.id.as_deref(), Some("apt-cache"));
850 assert_eq!(cm.sharing.as_deref(), Some("shared"));
851 assert!(!cm.readonly);
852 }
853
854 #[test]
855 fn test_step_with_cache_mounts() {
856 let yaml = r#"
857run: "apt-get update && apt-get install -y curl"
858cache:
859 - target: /var/cache/apt
860 id: apt-cache
861 sharing: shared
862 - target: /var/lib/apt
863 readonly: true
864"#;
865 let step: ZStep = serde_yaml::from_str(yaml).unwrap();
866 assert!(step.run.is_some());
867 assert_eq!(step.cache.len(), 2);
868 assert_eq!(step.cache[0].target, "/var/cache/apt");
869 assert!(step.cache[1].readonly);
870 }
871
872 #[test]
873 fn test_deny_unknown_fields_zimage() {
874 let yaml = r#"
875base: "alpine:3.19"
876bogus_field: "should fail"
877"#;
878 let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
879 assert!(result.is_err(), "Should reject unknown fields");
880 }
881
882 #[test]
883 fn test_deny_unknown_fields_zstep() {
884 let yaml = r#"
885run: "echo hello"
886bogus: "nope"
887"#;
888 let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
889 assert!(result.is_err(), "Should reject unknown fields on ZStep");
890 }
891
892 #[test]
893 fn test_roundtrip_serialize() {
894 let yaml = r#"
895base: "alpine:3.19"
896steps:
897 - run: "echo hello"
898 - copy: "."
899 to: "/app"
900cmd: "echo done"
901"#;
902 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
903 let serialized = serde_yaml::to_string(&img).unwrap();
904 let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
905 assert_eq!(img.base, img2.base);
906 assert_eq!(img.steps.len(), img2.steps.len());
907 }
908}