1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18use crate::backend::ImageOs;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(untagged)]
41pub enum ZBuildContext {
42 Short(String),
44 Full {
46 #[serde(alias = "context", default)]
49 workdir: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
53 file: Option<String>,
54 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
56 args: HashMap<String, String>,
57 },
58}
59
60impl ZBuildContext {
61 #[must_use]
63 pub fn context_dir(&self, base: &Path) -> PathBuf {
64 match self {
65 Self::Short(path) => base.join(path),
66 Self::Full { workdir, .. } => match workdir {
67 Some(dir) => base.join(dir),
68 None => base.to_path_buf(),
69 },
70 }
71 }
72
73 #[must_use]
75 pub fn file(&self) -> Option<&str> {
76 match self {
77 Self::Short(_) => None,
78 Self::Full { file, .. } => file.as_deref(),
79 }
80 }
81
82 #[must_use]
84 pub fn args(&self) -> HashMap<String, String> {
85 match self {
86 Self::Short(_) => HashMap::new(),
87 Self::Full { args, .. } => args.clone(),
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct ZImage {
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub version: Option<String>,
112
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub runtime: Option<String>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub base: Option<String>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub build: Option<ZBuildContext>,
128
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub steps: Vec<ZStep>,
132
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub platform: Option<String>,
136
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub os: Option<ImageOs>,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub stages: Option<IndexMap<String, ZStage>>,
146
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub wasm: Option<ZWasmConfig>,
151
152 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
155 pub env: HashMap<String, String>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub workdir: Option<String>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub expose: Option<ZExpose>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub cmd: Option<ZCommand>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub entrypoint: Option<ZCommand>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub user: Option<String>,
176
177 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
179 pub labels: HashMap<String, String>,
180
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub volumes: Vec<String>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub healthcheck: Option<ZHealthcheck>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub stopsignal: Option<String>,
192
193 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
195 pub args: HashMap<String, String>,
196}
197
198impl ZImage {
199 #[must_use]
212 pub fn resolve_target_os(&self) -> Option<ImageOs> {
213 if let Some(os) = self.os {
214 return Some(os);
215 }
216 if let Some(platform) = self.platform.as_deref() {
217 if let Ok(os) = platform.parse::<ImageOs>() {
218 return Some(os);
219 }
220 }
221 None
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct ZStage {
233 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub base: Option<String>,
237
238 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub build: Option<ZBuildContext>,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub platform: Option<String>,
246
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub os: Option<ImageOs>,
250
251 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
253 pub args: HashMap<String, String>,
254
255 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
257 pub env: HashMap<String, String>,
258
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub workdir: Option<String>,
262
263 #[serde(default, skip_serializing_if = "Vec::is_empty")]
265 pub steps: Vec<ZStep>,
266
267 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
269 pub labels: HashMap<String, String>,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub expose: Option<ZExpose>,
274
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub user: Option<String>,
278
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub entrypoint: Option<ZCommand>,
282
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub cmd: Option<ZCommand>,
286
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub volumes: Vec<String>,
290
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub healthcheck: Option<ZHealthcheck>,
294
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub stopsignal: Option<String>,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
310#[serde(deny_unknown_fields)]
311pub struct ZStep {
312 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub run: Option<ZCommand>,
316
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub copy: Option<ZCopySources>,
320
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub add: Option<ZCopySources>,
324
325 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub env: Option<HashMap<String, String>>,
328
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub workdir: Option<String>,
332
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub user: Option<String>,
336
337 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub to: Option<String>,
341
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub from: Option<String>,
345
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub owner: Option<String>,
349
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub chmod: Option<String>,
353
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub cache: Vec<ZCacheMount>,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct ZCacheMount {
369 pub target: String,
371
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub id: Option<String>,
375
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub sharing: Option<String>,
379
380 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
382 pub readonly: bool,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(untagged)]
405pub enum ZCommand {
406 Shell(String),
408 Exec(Vec<String>),
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(untagged)]
431pub enum ZCopySources {
432 Single(String),
434 Multiple(Vec<String>),
436}
437
438impl ZCopySources {
439 #[must_use]
441 pub fn to_vec(&self) -> Vec<String> {
442 match self {
443 Self::Single(s) => vec![s.clone()],
444 Self::Multiple(v) => v.clone(),
445 }
446 }
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
469#[serde(untagged)]
470pub enum ZExpose {
471 Single(u16),
473 Multiple(Vec<ZPortSpec>),
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(untagged)]
493pub enum ZPortSpec {
494 Number(u16),
496 WithProtocol(String),
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
517#[serde(deny_unknown_fields)]
518pub struct ZHealthcheck {
519 pub cmd: ZCommand,
521
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub interval: Option<String>,
525
526 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub timeout: Option<String>,
529
530 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub start_period: Option<String>,
533
534 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub retries: Option<u32>,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
566#[serde(deny_unknown_fields)]
567pub struct ZWasmConfig {
568 #[serde(default = "default_wasm_target")]
570 pub target: String,
571
572 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
574 pub optimize: bool,
575
576 #[serde(
578 default = "default_wasm_opt_level",
579 skip_serializing_if = "Option::is_none"
580 )]
581 pub opt_level: Option<String>,
582
583 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub language: Option<String>,
586
587 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub wit: Option<String>,
590
591 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub world: Option<String>,
595
596 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub output: Option<String>,
599
600 #[serde(default, skip_serializing_if = "Vec::is_empty")]
602 pub features: Vec<String>,
603
604 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
606 pub build_args: HashMap<String, String>,
607
608 #[serde(default, skip_serializing_if = "Vec::is_empty")]
610 pub pre_build: Vec<ZCommand>,
611
612 #[serde(default, skip_serializing_if = "Vec::is_empty")]
614 pub post_build: Vec<ZCommand>,
615
616 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub adapter: Option<String>,
619
620 #[serde(
623 default = "default_wasm_oci",
624 skip_serializing_if = "crate::zimage::types::is_true"
625 )]
626 pub oci: bool,
627}
628
629fn default_wasm_target() -> String {
635 "preview2".to_string()
636}
637
638#[allow(clippy::unnecessary_wraps)]
640fn default_wasm_opt_level() -> Option<String> {
641 Some("Oz".to_string())
642}
643
644#[allow(clippy::trivially_copy_pass_by_ref)]
646fn is_false(v: &bool) -> bool {
647 !v
648}
649
650#[allow(clippy::trivially_copy_pass_by_ref)]
652pub(crate) fn is_true(v: &bool) -> bool {
653 *v
654}
655
656fn default_wasm_oci() -> bool {
658 true
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 #[test]
666 fn test_runtime_mode_deserialize() {
667 let yaml = r#"
668runtime: node22
669cmd: "node server.js"
670"#;
671 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
672 assert_eq!(img.runtime.as_deref(), Some("node22"));
673 assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
674 }
675
676 #[test]
677 fn test_single_stage_deserialize() {
678 let yaml = r#"
679base: "alpine:3.19"
680steps:
681 - run: "apk add --no-cache curl"
682 - copy: "app.sh"
683 to: "/usr/local/bin/app.sh"
684 chmod: "755"
685 - workdir: "/app"
686env:
687 NODE_ENV: production
688expose: 8080
689cmd: ["./app.sh"]
690"#;
691 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
692 assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
693 assert_eq!(img.steps.len(), 3);
694 assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
695 assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
696 assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
697 }
698
699 #[test]
700 fn test_multi_stage_deserialize() {
701 let yaml = r#"
702stages:
703 builder:
704 base: "node:22-alpine"
705 workdir: "/src"
706 steps:
707 - copy: ["package.json", "package-lock.json"]
708 to: "./"
709 - run: "npm ci"
710 - copy: "."
711 to: "."
712 - run: "npm run build"
713 runtime:
714 base: "node:22-alpine"
715 workdir: "/app"
716 steps:
717 - copy: "dist"
718 from: builder
719 to: "/app"
720 cmd: ["node", "dist/index.js"]
721expose: 3000
722"#;
723 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
724 let stages = img.stages.as_ref().unwrap();
725 assert_eq!(stages.len(), 2);
726
727 let keys: Vec<&String> = stages.keys().collect();
729 assert_eq!(keys, vec!["builder", "runtime"]);
730
731 let builder = &stages["builder"];
732 assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
733 assert_eq!(builder.steps.len(), 4);
734
735 let runtime = &stages["runtime"];
736 assert_eq!(runtime.steps.len(), 1);
737 assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
738 }
739
740 #[test]
741 fn test_wasm_mode_deserialize() {
742 let yaml = r#"
743wasm:
744 target: preview2
745 optimize: true
746 language: rust
747 wit: "./wit"
748 output: "./output.wasm"
749"#;
750 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
751 let wasm = img.wasm.as_ref().unwrap();
752 assert_eq!(wasm.target, "preview2");
753 assert!(wasm.optimize);
754 assert_eq!(wasm.language.as_deref(), Some("rust"));
755 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
756 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
757 }
758
759 #[test]
760 fn test_wasm_defaults() {
761 let yaml = r"
762wasm: {}
763";
764 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
765 let wasm = img.wasm.as_ref().unwrap();
766 assert_eq!(wasm.target, "preview2");
767 assert!(!wasm.optimize);
768 assert!(wasm.language.is_none());
769 assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
770 assert!(wasm.world.is_none());
771 assert!(wasm.features.is_empty());
772 assert!(wasm.build_args.is_empty());
773 assert!(wasm.pre_build.is_empty());
774 assert!(wasm.post_build.is_empty());
775 assert!(wasm.adapter.is_none());
776 assert!(
777 wasm.oci,
778 "default ZWasmConfig.oci must be true so OCI packaging still happens unless explicitly opted out"
779 );
780 }
781
782 #[test]
783 fn test_wasm_oci_opt_out() {
784 let yaml = r"
785wasm:
786 oci: false
787";
788 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
789 let wasm = img.wasm.as_ref().unwrap();
790 assert!(
791 !wasm.oci,
792 "wasm.oci: false must deserialize to ZWasmConfig.oci == false"
793 );
794 }
795
796 #[test]
797 fn test_wasm_full_config() {
798 let yaml = r#"
799wasm:
800 target: "preview2"
801 optimize: true
802 opt_level: "O3"
803 language: "rust"
804 world: "zlayer-http-handler"
805 wit: "./wit"
806 output: "./output.wasm"
807 features:
808 - json
809 - metrics
810 build_args:
811 CARGO_PROFILE_RELEASE_LTO: "true"
812 RUSTFLAGS: "-C target-feature=+simd128"
813 pre_build:
814 - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
815 post_build:
816 - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
817 adapter: "./wasi_snapshot_preview1.reactor.wasm"
818"#;
819 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
820 let wasm = img.wasm.as_ref().unwrap();
821 assert_eq!(wasm.target, "preview2");
822 assert!(wasm.optimize);
823 assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
824 assert_eq!(wasm.language.as_deref(), Some("rust"));
825 assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
826 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
827 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
828 assert_eq!(wasm.features, vec!["json", "metrics"]);
829 assert_eq!(
830 wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
831 "true"
832 );
833 assert_eq!(
834 wasm.build_args.get("RUSTFLAGS").unwrap(),
835 "-C target-feature=+simd128"
836 );
837 assert_eq!(wasm.pre_build.len(), 1);
838 assert_eq!(wasm.post_build.len(), 1);
839 assert_eq!(
840 wasm.adapter.as_deref(),
841 Some("./wasi_snapshot_preview1.reactor.wasm")
842 );
843 }
844
845 #[test]
846 fn test_zcommand_shell() {
847 let yaml = r#""echo hello""#;
848 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
849 assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
850 }
851
852 #[test]
853 fn test_zcommand_exec() {
854 let yaml = r#"["echo", "hello"]"#;
855 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
856 assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
857 }
858
859 #[test]
860 fn test_zcopy_sources_single() {
861 let yaml = r#""package.json""#;
862 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
863 assert_eq!(src.to_vec(), vec!["package.json"]);
864 }
865
866 #[test]
867 fn test_zcopy_sources_multiple() {
868 let yaml = r#"["package.json", "tsconfig.json"]"#;
869 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
870 assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
871 }
872
873 #[test]
874 fn test_zexpose_single() {
875 let yaml = "8080";
876 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
877 assert!(matches!(exp, ZExpose::Single(8080)));
878 }
879
880 #[test]
881 fn test_zexpose_multiple() {
882 let yaml = r#"
883- 8080
884- "9090/udp"
885"#;
886 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
887 if let ZExpose::Multiple(ports) = exp {
888 assert_eq!(ports.len(), 2);
889 assert!(matches!(ports[0], ZPortSpec::Number(8080)));
890 assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
891 } else {
892 panic!("Expected ZExpose::Multiple");
893 }
894 }
895
896 #[test]
897 fn test_healthcheck_deserialize() {
898 let yaml = r#"
899cmd: "curl -f http://localhost/ || exit 1"
900interval: "30s"
901timeout: "10s"
902start_period: "5s"
903retries: 3
904"#;
905 let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
906 assert!(matches!(hc.cmd, ZCommand::Shell(_)));
907 assert_eq!(hc.interval.as_deref(), Some("30s"));
908 assert_eq!(hc.timeout.as_deref(), Some("10s"));
909 assert_eq!(hc.start_period.as_deref(), Some("5s"));
910 assert_eq!(hc.retries, Some(3));
911 }
912
913 #[test]
914 fn test_cache_mount_deserialize() {
915 let yaml = r"
916target: /var/cache/apt
917id: apt-cache
918sharing: shared
919readonly: false
920";
921 let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
922 assert_eq!(cm.target, "/var/cache/apt");
923 assert_eq!(cm.id.as_deref(), Some("apt-cache"));
924 assert_eq!(cm.sharing.as_deref(), Some("shared"));
925 assert!(!cm.readonly);
926 }
927
928 #[test]
929 fn test_step_with_cache_mounts() {
930 let yaml = r#"
931run: "apt-get update && apt-get install -y curl"
932cache:
933 - target: /var/cache/apt
934 id: apt-cache
935 sharing: shared
936 - target: /var/lib/apt
937 readonly: true
938"#;
939 let step: ZStep = serde_yaml::from_str(yaml).unwrap();
940 assert!(step.run.is_some());
941 assert_eq!(step.cache.len(), 2);
942 assert_eq!(step.cache[0].target, "/var/cache/apt");
943 assert!(step.cache[1].readonly);
944 }
945
946 #[test]
947 fn test_deny_unknown_fields_zimage() {
948 let yaml = r#"
949base: "alpine:3.19"
950bogus_field: "should fail"
951"#;
952 let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
953 assert!(result.is_err(), "Should reject unknown fields");
954 }
955
956 #[test]
957 fn test_deny_unknown_fields_zstep() {
958 let yaml = r#"
959run: "echo hello"
960bogus: "nope"
961"#;
962 let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
963 assert!(result.is_err(), "Should reject unknown fields on ZStep");
964 }
965
966 #[test]
971 fn test_zimage_os_field_linux() {
972 let yaml = r#"
973base: "alpine:3.19"
974os: linux
975"#;
976 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
977 assert_eq!(img.os, Some(ImageOs::Linux));
978 assert_eq!(img.resolve_target_os(), Some(ImageOs::Linux));
979 }
980
981 #[test]
982 fn test_zimage_os_field_windows() {
983 let yaml = r#"
984base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
985os: windows
986"#;
987 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
988 assert_eq!(img.os, Some(ImageOs::Windows));
989 assert_eq!(img.resolve_target_os(), Some(ImageOs::Windows));
990 }
991
992 #[test]
993 fn test_zimage_os_missing_is_none() {
994 let yaml = r#"
998base: "alpine:3.19"
999"#;
1000 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1001 assert_eq!(img.os, None);
1002 assert_eq!(img.resolve_target_os(), None);
1003 }
1004
1005 #[test]
1006 fn test_zimage_os_wins_over_platform() {
1007 let yaml = r#"
1011base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1012platform: "linux/amd64"
1013os: windows
1014"#;
1015 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1016 assert_eq!(img.platform.as_deref(), Some("linux/amd64"));
1017 assert_eq!(img.os, Some(ImageOs::Windows));
1018 assert_eq!(
1019 img.resolve_target_os(),
1020 Some(ImageOs::Windows),
1021 "explicit os: must win over OS inferred from platform:"
1022 );
1023 }
1024
1025 #[test]
1026 fn test_zimage_os_inferred_from_platform() {
1027 let yaml = r#"
1029base: "alpine:3.19"
1030platform: "linux/arm64"
1031"#;
1032 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1033 assert_eq!(img.os, None);
1034 assert_eq!(
1035 img.resolve_target_os(),
1036 Some(ImageOs::Linux),
1037 "resolve_target_os must fall back to parsing platform:"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_zimage_os_unknown_platform_ignored() {
1043 let yaml = r#"
1046base: "alpine:3.19"
1047platform: "amd64"
1048"#;
1049 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1050 assert_eq!(img.resolve_target_os(), None);
1051 }
1052
1053 #[test]
1054 fn test_zstage_os_field() {
1055 let yaml = r#"
1056stages:
1057 builder:
1058 base: "alpine:3.19"
1059 os: linux
1060 runtime:
1061 base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1062 os: windows
1063"#;
1064 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1065 let stages = img.stages.as_ref().unwrap();
1066 assert_eq!(stages["builder"].os, Some(ImageOs::Linux));
1067 assert_eq!(stages["runtime"].os, Some(ImageOs::Windows));
1068 }
1069
1070 #[test]
1071 fn test_roundtrip_serialize() {
1072 let yaml = r#"
1073base: "alpine:3.19"
1074steps:
1075 - run: "echo hello"
1076 - copy: "."
1077 to: "/app"
1078cmd: "echo done"
1079"#;
1080 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1081 let serialized = serde_yaml::to_string(&img).unwrap();
1082 let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
1083 assert_eq!(img.base, img2.base);
1084 assert_eq!(img.steps.len(), img2.steps.len());
1085 }
1086}