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 #[serde(
586 default = "default_wasm_oci",
587 skip_serializing_if = "crate::zimage::types::is_true"
588 )]
589 pub oci: bool,
590}
591
592fn default_wasm_target() -> String {
598 "preview2".to_string()
599}
600
601#[allow(clippy::unnecessary_wraps)]
603fn default_wasm_opt_level() -> Option<String> {
604 Some("Oz".to_string())
605}
606
607#[allow(clippy::trivially_copy_pass_by_ref)]
609fn is_false(v: &bool) -> bool {
610 !v
611}
612
613#[allow(clippy::trivially_copy_pass_by_ref)]
615pub(crate) fn is_true(v: &bool) -> bool {
616 *v
617}
618
619fn default_wasm_oci() -> bool {
621 true
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[test]
629 fn test_runtime_mode_deserialize() {
630 let yaml = r#"
631runtime: node22
632cmd: "node server.js"
633"#;
634 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
635 assert_eq!(img.runtime.as_deref(), Some("node22"));
636 assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
637 }
638
639 #[test]
640 fn test_single_stage_deserialize() {
641 let yaml = r#"
642base: "alpine:3.19"
643steps:
644 - run: "apk add --no-cache curl"
645 - copy: "app.sh"
646 to: "/usr/local/bin/app.sh"
647 chmod: "755"
648 - workdir: "/app"
649env:
650 NODE_ENV: production
651expose: 8080
652cmd: ["./app.sh"]
653"#;
654 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
655 assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
656 assert_eq!(img.steps.len(), 3);
657 assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
658 assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
659 assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
660 }
661
662 #[test]
663 fn test_multi_stage_deserialize() {
664 let yaml = r#"
665stages:
666 builder:
667 base: "node:22-alpine"
668 workdir: "/src"
669 steps:
670 - copy: ["package.json", "package-lock.json"]
671 to: "./"
672 - run: "npm ci"
673 - copy: "."
674 to: "."
675 - run: "npm run build"
676 runtime:
677 base: "node:22-alpine"
678 workdir: "/app"
679 steps:
680 - copy: "dist"
681 from: builder
682 to: "/app"
683 cmd: ["node", "dist/index.js"]
684expose: 3000
685"#;
686 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
687 let stages = img.stages.as_ref().unwrap();
688 assert_eq!(stages.len(), 2);
689
690 let keys: Vec<&String> = stages.keys().collect();
692 assert_eq!(keys, vec!["builder", "runtime"]);
693
694 let builder = &stages["builder"];
695 assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
696 assert_eq!(builder.steps.len(), 4);
697
698 let runtime = &stages["runtime"];
699 assert_eq!(runtime.steps.len(), 1);
700 assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
701 }
702
703 #[test]
704 fn test_wasm_mode_deserialize() {
705 let yaml = r#"
706wasm:
707 target: preview2
708 optimize: true
709 language: rust
710 wit: "./wit"
711 output: "./output.wasm"
712"#;
713 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
714 let wasm = img.wasm.as_ref().unwrap();
715 assert_eq!(wasm.target, "preview2");
716 assert!(wasm.optimize);
717 assert_eq!(wasm.language.as_deref(), Some("rust"));
718 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
719 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
720 }
721
722 #[test]
723 fn test_wasm_defaults() {
724 let yaml = r"
725wasm: {}
726";
727 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
728 let wasm = img.wasm.as_ref().unwrap();
729 assert_eq!(wasm.target, "preview2");
730 assert!(!wasm.optimize);
731 assert!(wasm.language.is_none());
732 assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
733 assert!(wasm.world.is_none());
734 assert!(wasm.features.is_empty());
735 assert!(wasm.build_args.is_empty());
736 assert!(wasm.pre_build.is_empty());
737 assert!(wasm.post_build.is_empty());
738 assert!(wasm.adapter.is_none());
739 assert!(
740 wasm.oci,
741 "default ZWasmConfig.oci must be true so OCI packaging still happens unless explicitly opted out"
742 );
743 }
744
745 #[test]
746 fn test_wasm_oci_opt_out() {
747 let yaml = r"
748wasm:
749 oci: false
750";
751 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
752 let wasm = img.wasm.as_ref().unwrap();
753 assert!(
754 !wasm.oci,
755 "wasm.oci: false must deserialize to ZWasmConfig.oci == false"
756 );
757 }
758
759 #[test]
760 fn test_wasm_full_config() {
761 let yaml = r#"
762wasm:
763 target: "preview2"
764 optimize: true
765 opt_level: "O3"
766 language: "rust"
767 world: "zlayer-http-handler"
768 wit: "./wit"
769 output: "./output.wasm"
770 features:
771 - json
772 - metrics
773 build_args:
774 CARGO_PROFILE_RELEASE_LTO: "true"
775 RUSTFLAGS: "-C target-feature=+simd128"
776 pre_build:
777 - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
778 post_build:
779 - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
780 adapter: "./wasi_snapshot_preview1.reactor.wasm"
781"#;
782 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
783 let wasm = img.wasm.as_ref().unwrap();
784 assert_eq!(wasm.target, "preview2");
785 assert!(wasm.optimize);
786 assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
787 assert_eq!(wasm.language.as_deref(), Some("rust"));
788 assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
789 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
790 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
791 assert_eq!(wasm.features, vec!["json", "metrics"]);
792 assert_eq!(
793 wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
794 "true"
795 );
796 assert_eq!(
797 wasm.build_args.get("RUSTFLAGS").unwrap(),
798 "-C target-feature=+simd128"
799 );
800 assert_eq!(wasm.pre_build.len(), 1);
801 assert_eq!(wasm.post_build.len(), 1);
802 assert_eq!(
803 wasm.adapter.as_deref(),
804 Some("./wasi_snapshot_preview1.reactor.wasm")
805 );
806 }
807
808 #[test]
809 fn test_zcommand_shell() {
810 let yaml = r#""echo hello""#;
811 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
812 assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
813 }
814
815 #[test]
816 fn test_zcommand_exec() {
817 let yaml = r#"["echo", "hello"]"#;
818 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
819 assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
820 }
821
822 #[test]
823 fn test_zcopy_sources_single() {
824 let yaml = r#""package.json""#;
825 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
826 assert_eq!(src.to_vec(), vec!["package.json"]);
827 }
828
829 #[test]
830 fn test_zcopy_sources_multiple() {
831 let yaml = r#"["package.json", "tsconfig.json"]"#;
832 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
833 assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
834 }
835
836 #[test]
837 fn test_zexpose_single() {
838 let yaml = "8080";
839 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
840 assert!(matches!(exp, ZExpose::Single(8080)));
841 }
842
843 #[test]
844 fn test_zexpose_multiple() {
845 let yaml = r#"
846- 8080
847- "9090/udp"
848"#;
849 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
850 if let ZExpose::Multiple(ports) = exp {
851 assert_eq!(ports.len(), 2);
852 assert!(matches!(ports[0], ZPortSpec::Number(8080)));
853 assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
854 } else {
855 panic!("Expected ZExpose::Multiple");
856 }
857 }
858
859 #[test]
860 fn test_healthcheck_deserialize() {
861 let yaml = r#"
862cmd: "curl -f http://localhost/ || exit 1"
863interval: "30s"
864timeout: "10s"
865start_period: "5s"
866retries: 3
867"#;
868 let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
869 assert!(matches!(hc.cmd, ZCommand::Shell(_)));
870 assert_eq!(hc.interval.as_deref(), Some("30s"));
871 assert_eq!(hc.timeout.as_deref(), Some("10s"));
872 assert_eq!(hc.start_period.as_deref(), Some("5s"));
873 assert_eq!(hc.retries, Some(3));
874 }
875
876 #[test]
877 fn test_cache_mount_deserialize() {
878 let yaml = r"
879target: /var/cache/apt
880id: apt-cache
881sharing: shared
882readonly: false
883";
884 let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
885 assert_eq!(cm.target, "/var/cache/apt");
886 assert_eq!(cm.id.as_deref(), Some("apt-cache"));
887 assert_eq!(cm.sharing.as_deref(), Some("shared"));
888 assert!(!cm.readonly);
889 }
890
891 #[test]
892 fn test_step_with_cache_mounts() {
893 let yaml = r#"
894run: "apt-get update && apt-get install -y curl"
895cache:
896 - target: /var/cache/apt
897 id: apt-cache
898 sharing: shared
899 - target: /var/lib/apt
900 readonly: true
901"#;
902 let step: ZStep = serde_yaml::from_str(yaml).unwrap();
903 assert!(step.run.is_some());
904 assert_eq!(step.cache.len(), 2);
905 assert_eq!(step.cache[0].target, "/var/cache/apt");
906 assert!(step.cache[1].readonly);
907 }
908
909 #[test]
910 fn test_deny_unknown_fields_zimage() {
911 let yaml = r#"
912base: "alpine:3.19"
913bogus_field: "should fail"
914"#;
915 let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
916 assert!(result.is_err(), "Should reject unknown fields");
917 }
918
919 #[test]
920 fn test_deny_unknown_fields_zstep() {
921 let yaml = r#"
922run: "echo hello"
923bogus: "nope"
924"#;
925 let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
926 assert!(result.is_err(), "Should reject unknown fields on ZStep");
927 }
928
929 #[test]
930 fn test_roundtrip_serialize() {
931 let yaml = r#"
932base: "alpine:3.19"
933steps:
934 - run: "echo hello"
935 - copy: "."
936 to: "/app"
937cmd: "echo done"
938"#;
939 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
940 let serialized = serde_yaml::to_string(&img).unwrap();
941 let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
942 assert_eq!(img.base, img2.base);
943 assert_eq!(img.steps.len(), img2.steps.len());
944 }
945}