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, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "kebab-case")]
104pub enum ZIsolation {
105 Auto,
107 Sandbox,
109 Vz,
111 VzLinux,
113 Vm,
115 Native,
117 Container,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ZImage {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub version: Option<String>,
141
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub runtime: Option<String>,
146
147 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub base: Option<String>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub build: Option<ZBuildContext>,
157
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub steps: Vec<ZStep>,
161
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub platform: Option<String>,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub os: Option<ImageOs>,
169
170 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub isolation: Option<ZIsolation>,
176
177 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub stages: Option<IndexMap<String, ZStage>>,
182
183 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub wasm: Option<ZWasmConfig>,
187
188 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
191 pub env: HashMap<String, String>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub workdir: Option<String>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub expose: Option<ZExpose>,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub cmd: Option<ZCommand>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub entrypoint: Option<ZCommand>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub user: Option<String>,
212
213 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
215 pub labels: HashMap<String, String>,
216
217 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub volumes: Vec<String>,
220
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub healthcheck: Option<ZHealthcheck>,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub stopsignal: Option<String>,
228
229 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
231 pub args: HashMap<String, String>,
232}
233
234impl ZImage {
235 #[must_use]
248 pub fn resolve_target_os(&self) -> Option<ImageOs> {
249 if let Some(os) = self.os {
250 return Some(os);
251 }
252 if let Some(platform) = self.platform.as_deref() {
253 if let Ok(os) = platform.parse::<ImageOs>() {
254 return Some(os);
255 }
256 }
257 None
258 }
259
260 #[must_use]
268 pub fn resolve_isolation(&self) -> Option<zlayer_types::spec::RuntimeIsolation> {
269 use crate::backend::ImageOs;
270 use zlayer_types::spec::RuntimeIsolation as RI;
271 self.isolation.map(|z| match z {
272 ZIsolation::Auto => RI::Auto,
273 ZIsolation::Sandbox | ZIsolation::Native => RI::Sandbox,
274 ZIsolation::Vz => RI::Vz,
275 ZIsolation::VzLinux => RI::VzLinux,
276 ZIsolation::Vm => RI::Vm,
277 ZIsolation::Container => match self.resolve_target_os() {
278 Some(ImageOs::Darwin) => RI::Vz,
279 _ => RI::VzLinux,
280 },
281 })
282 }
283}
284
285#[must_use]
296pub fn detect_image_os_from_binary(
297 zimage: &ZImage,
298 context: &std::path::Path,
299) -> Option<crate::backend::ImageOs> {
300 let cmd = zimage.entrypoint.as_ref().or(zimage.cmd.as_ref())?;
302 let prog = command_first_token(cmd)?;
303 if prog.is_empty() {
304 return None;
305 }
306 let prog_path = Path::new(&prog);
307 let prog_base = prog_path.file_name()?;
308 let prog_parent = prog_path.parent();
309
310 for step in &zimage.steps {
313 let (Some(to), Some(copy)) = (step.to.as_deref(), step.copy.as_ref()) else {
314 continue;
315 };
316 let to_norm = Path::new(to.trim_end_matches('/'));
318 let exact = to_norm == prog_path;
319 let is_parent_dir = prog_parent.is_some_and(|p| !p.as_os_str().is_empty() && to_norm == p);
320 if !exact && !is_parent_dir {
321 continue;
322 }
323
324 for src in copy.to_vec() {
325 let base = context.join(&src);
326 for candidate in [base.clone(), base.join(prog_base)] {
330 if let Some(os) = read_magic_and_classify(&candidate) {
331 return Some(os);
332 }
333 }
334 }
335 }
336
337 None
338}
339
340fn command_first_token(cmd: &ZCommand) -> Option<String> {
343 match cmd {
344 ZCommand::Exec(v) => v.first().cloned(),
345 ZCommand::Shell(s) => s.split_whitespace().next().map(ToString::to_string),
346 }
347}
348
349fn read_magic_and_classify(path: &Path) -> Option<crate::backend::ImageOs> {
353 use std::io::Read;
354
355 if !path.is_file() {
356 return None;
357 }
358 let mut file = std::fs::File::open(path).ok()?;
359 let mut buf = [0u8; 8];
360 let n = file.read(&mut buf).ok()?;
361 if n == 0 {
362 return None;
363 }
364 crate::backend::image_os_from_magic(&buf[..n])
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
373#[serde(deny_unknown_fields)]
374pub struct ZStage {
375 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub base: Option<String>,
379
380 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub build: Option<ZBuildContext>,
384
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub platform: Option<String>,
388
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub os: Option<ImageOs>,
392
393 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub isolation: Option<ZIsolation>,
398
399 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
401 pub args: HashMap<String, String>,
402
403 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
405 pub env: HashMap<String, String>,
406
407 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub workdir: Option<String>,
410
411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
413 pub steps: Vec<ZStep>,
414
415 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
417 pub labels: HashMap<String, String>,
418
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub expose: Option<ZExpose>,
422
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub user: Option<String>,
426
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub entrypoint: Option<ZCommand>,
430
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub cmd: Option<ZCommand>,
434
435 #[serde(default, skip_serializing_if = "Vec::is_empty")]
437 pub volumes: Vec<String>,
438
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub healthcheck: Option<ZHealthcheck>,
442
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub stopsignal: Option<String>,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
468#[serde(deny_unknown_fields)]
469pub struct ZStep {
470 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub run: Option<ZCommand>,
474
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub copy: Option<ZCopySources>,
478
479 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub add: Option<ZCopySources>,
482
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub env: Option<HashMap<String, String>>,
486
487 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub workdir: Option<String>,
490
491 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub user: Option<String>,
494
495 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub to: Option<String>,
499
500 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub from: Option<String>,
503
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub owner: Option<String>,
507
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub chmod: Option<String>,
511
512 #[serde(default, skip_serializing_if = "Vec::is_empty")]
514 pub cache: Vec<ZCacheMount>,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
525#[serde(deny_unknown_fields)]
526pub struct ZCacheMount {
527 pub target: String,
529
530 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub id: Option<String>,
533
534 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub sharing: Option<String>,
537
538 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
540 pub readonly: bool,
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
562#[serde(untagged)]
563pub enum ZCommand {
564 Shell(String),
566 Exec(Vec<String>),
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(untagged)]
589pub enum ZCopySources {
590 Single(String),
592 Multiple(Vec<String>),
594}
595
596impl ZCopySources {
597 #[must_use]
599 pub fn to_vec(&self) -> Vec<String> {
600 match self {
601 Self::Single(s) => vec![s.clone()],
602 Self::Multiple(v) => v.clone(),
603 }
604 }
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize)]
627#[serde(untagged)]
628pub enum ZExpose {
629 Single(u16),
631 Multiple(Vec<ZPortSpec>),
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
650#[serde(untagged)]
651pub enum ZPortSpec {
652 Number(u16),
654 WithProtocol(String),
656}
657
658#[derive(Debug, Clone, Serialize, Deserialize)]
675#[serde(deny_unknown_fields)]
676pub struct ZHealthcheck {
677 pub cmd: ZCommand,
679
680 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub interval: Option<String>,
683
684 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub timeout: Option<String>,
687
688 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub start_period: Option<String>,
691
692 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub retries: Option<u32>,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize)]
724#[serde(deny_unknown_fields)]
725pub struct ZWasmConfig {
726 #[serde(default = "default_wasm_target")]
728 pub target: String,
729
730 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
732 pub optimize: bool,
733
734 #[serde(
736 default = "default_wasm_opt_level",
737 skip_serializing_if = "Option::is_none"
738 )]
739 pub opt_level: Option<String>,
740
741 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub language: Option<String>,
744
745 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub wit: Option<String>,
748
749 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub world: Option<String>,
753
754 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub output: Option<String>,
757
758 #[serde(default, skip_serializing_if = "Vec::is_empty")]
760 pub features: Vec<String>,
761
762 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
764 pub build_args: HashMap<String, String>,
765
766 #[serde(default, skip_serializing_if = "Vec::is_empty")]
768 pub pre_build: Vec<ZCommand>,
769
770 #[serde(default, skip_serializing_if = "Vec::is_empty")]
772 pub post_build: Vec<ZCommand>,
773
774 #[serde(default, skip_serializing_if = "Option::is_none")]
776 pub adapter: Option<String>,
777
778 #[serde(
781 default = "default_wasm_oci",
782 skip_serializing_if = "crate::zimage::types::is_true"
783 )]
784 pub oci: bool,
785}
786
787fn default_wasm_target() -> String {
793 "preview2".to_string()
794}
795
796#[allow(clippy::unnecessary_wraps)]
798fn default_wasm_opt_level() -> Option<String> {
799 Some("Oz".to_string())
800}
801
802#[allow(clippy::trivially_copy_pass_by_ref)]
804fn is_false(v: &bool) -> bool {
805 !v
806}
807
808#[allow(clippy::trivially_copy_pass_by_ref)]
810pub(crate) fn is_true(v: &bool) -> bool {
811 *v
812}
813
814fn default_wasm_oci() -> bool {
816 true
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn test_runtime_mode_deserialize() {
825 let yaml = r#"
826runtime: node22
827cmd: "node server.js"
828"#;
829 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
830 assert_eq!(img.runtime.as_deref(), Some("node22"));
831 assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
832 }
833
834 #[test]
835 fn test_single_stage_deserialize() {
836 let yaml = r#"
837base: "alpine:3.19"
838steps:
839 - run: "apk add --no-cache curl"
840 - copy: "app.sh"
841 to: "/usr/local/bin/app.sh"
842 chmod: "755"
843 - workdir: "/app"
844env:
845 NODE_ENV: production
846expose: 8080
847cmd: ["./app.sh"]
848"#;
849 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
850 assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
851 assert_eq!(img.steps.len(), 3);
852 assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
853 assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
854 assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
855 }
856
857 #[test]
858 fn test_multi_stage_deserialize() {
859 let yaml = r#"
860stages:
861 builder:
862 base: "node:22-alpine"
863 workdir: "/src"
864 steps:
865 - copy: ["package.json", "package-lock.json"]
866 to: "./"
867 - run: "npm ci"
868 - copy: "."
869 to: "."
870 - run: "npm run build"
871 runtime:
872 base: "node:22-alpine"
873 workdir: "/app"
874 steps:
875 - copy: "dist"
876 from: builder
877 to: "/app"
878 cmd: ["node", "dist/index.js"]
879expose: 3000
880"#;
881 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
882 let stages = img.stages.as_ref().unwrap();
883 assert_eq!(stages.len(), 2);
884
885 let keys: Vec<&String> = stages.keys().collect();
887 assert_eq!(keys, vec!["builder", "runtime"]);
888
889 let builder = &stages["builder"];
890 assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
891 assert_eq!(builder.steps.len(), 4);
892
893 let runtime = &stages["runtime"];
894 assert_eq!(runtime.steps.len(), 1);
895 assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
896 }
897
898 #[test]
899 fn test_wasm_mode_deserialize() {
900 let yaml = r#"
901wasm:
902 target: preview2
903 optimize: true
904 language: rust
905 wit: "./wit"
906 output: "./output.wasm"
907"#;
908 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
909 let wasm = img.wasm.as_ref().unwrap();
910 assert_eq!(wasm.target, "preview2");
911 assert!(wasm.optimize);
912 assert_eq!(wasm.language.as_deref(), Some("rust"));
913 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
914 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
915 }
916
917 #[test]
918 fn test_wasm_defaults() {
919 let yaml = r"
920wasm: {}
921";
922 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
923 let wasm = img.wasm.as_ref().unwrap();
924 assert_eq!(wasm.target, "preview2");
925 assert!(!wasm.optimize);
926 assert!(wasm.language.is_none());
927 assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
928 assert!(wasm.world.is_none());
929 assert!(wasm.features.is_empty());
930 assert!(wasm.build_args.is_empty());
931 assert!(wasm.pre_build.is_empty());
932 assert!(wasm.post_build.is_empty());
933 assert!(wasm.adapter.is_none());
934 assert!(
935 wasm.oci,
936 "default ZWasmConfig.oci must be true so OCI packaging still happens unless explicitly opted out"
937 );
938 }
939
940 #[test]
941 fn test_wasm_oci_opt_out() {
942 let yaml = r"
943wasm:
944 oci: false
945";
946 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
947 let wasm = img.wasm.as_ref().unwrap();
948 assert!(
949 !wasm.oci,
950 "wasm.oci: false must deserialize to ZWasmConfig.oci == false"
951 );
952 }
953
954 #[test]
955 fn test_wasm_full_config() {
956 let yaml = r#"
957wasm:
958 target: "preview2"
959 optimize: true
960 opt_level: "O3"
961 language: "rust"
962 world: "zlayer-http-handler"
963 wit: "./wit"
964 output: "./output.wasm"
965 features:
966 - json
967 - metrics
968 build_args:
969 CARGO_PROFILE_RELEASE_LTO: "true"
970 RUSTFLAGS: "-C target-feature=+simd128"
971 pre_build:
972 - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
973 post_build:
974 - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
975 adapter: "./wasi_snapshot_preview1.reactor.wasm"
976"#;
977 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
978 let wasm = img.wasm.as_ref().unwrap();
979 assert_eq!(wasm.target, "preview2");
980 assert!(wasm.optimize);
981 assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
982 assert_eq!(wasm.language.as_deref(), Some("rust"));
983 assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
984 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
985 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
986 assert_eq!(wasm.features, vec!["json", "metrics"]);
987 assert_eq!(
988 wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
989 "true"
990 );
991 assert_eq!(
992 wasm.build_args.get("RUSTFLAGS").unwrap(),
993 "-C target-feature=+simd128"
994 );
995 assert_eq!(wasm.pre_build.len(), 1);
996 assert_eq!(wasm.post_build.len(), 1);
997 assert_eq!(
998 wasm.adapter.as_deref(),
999 Some("./wasi_snapshot_preview1.reactor.wasm")
1000 );
1001 }
1002
1003 #[test]
1004 fn test_zcommand_shell() {
1005 let yaml = r#""echo hello""#;
1006 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
1007 assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
1008 }
1009
1010 #[test]
1011 fn test_zcommand_exec() {
1012 let yaml = r#"["echo", "hello"]"#;
1013 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
1014 assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
1015 }
1016
1017 #[test]
1018 fn test_zcopy_sources_single() {
1019 let yaml = r#""package.json""#;
1020 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
1021 assert_eq!(src.to_vec(), vec!["package.json"]);
1022 }
1023
1024 #[test]
1025 fn test_zcopy_sources_multiple() {
1026 let yaml = r#"["package.json", "tsconfig.json"]"#;
1027 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
1028 assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
1029 }
1030
1031 #[test]
1032 fn test_zexpose_single() {
1033 let yaml = "8080";
1034 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
1035 assert!(matches!(exp, ZExpose::Single(8080)));
1036 }
1037
1038 #[test]
1039 fn test_zexpose_multiple() {
1040 let yaml = r#"
1041- 8080
1042- "9090/udp"
1043"#;
1044 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
1045 if let ZExpose::Multiple(ports) = exp {
1046 assert_eq!(ports.len(), 2);
1047 assert!(matches!(ports[0], ZPortSpec::Number(8080)));
1048 assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
1049 } else {
1050 panic!("Expected ZExpose::Multiple");
1051 }
1052 }
1053
1054 #[test]
1055 fn test_healthcheck_deserialize() {
1056 let yaml = r#"
1057cmd: "curl -f http://localhost/ || exit 1"
1058interval: "30s"
1059timeout: "10s"
1060start_period: "5s"
1061retries: 3
1062"#;
1063 let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
1064 assert!(matches!(hc.cmd, ZCommand::Shell(_)));
1065 assert_eq!(hc.interval.as_deref(), Some("30s"));
1066 assert_eq!(hc.timeout.as_deref(), Some("10s"));
1067 assert_eq!(hc.start_period.as_deref(), Some("5s"));
1068 assert_eq!(hc.retries, Some(3));
1069 }
1070
1071 #[test]
1072 fn test_cache_mount_deserialize() {
1073 let yaml = r"
1074target: /var/cache/apt
1075id: apt-cache
1076sharing: shared
1077readonly: false
1078";
1079 let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
1080 assert_eq!(cm.target, "/var/cache/apt");
1081 assert_eq!(cm.id.as_deref(), Some("apt-cache"));
1082 assert_eq!(cm.sharing.as_deref(), Some("shared"));
1083 assert!(!cm.readonly);
1084 }
1085
1086 #[test]
1087 fn test_step_with_cache_mounts() {
1088 let yaml = r#"
1089run: "apt-get update && apt-get install -y curl"
1090cache:
1091 - target: /var/cache/apt
1092 id: apt-cache
1093 sharing: shared
1094 - target: /var/lib/apt
1095 readonly: true
1096"#;
1097 let step: ZStep = serde_yaml::from_str(yaml).unwrap();
1098 assert!(step.run.is_some());
1099 assert_eq!(step.cache.len(), 2);
1100 assert_eq!(step.cache[0].target, "/var/cache/apt");
1101 assert!(step.cache[1].readonly);
1102 }
1103
1104 #[test]
1105 fn test_deny_unknown_fields_zimage() {
1106 let yaml = r#"
1107base: "alpine:3.19"
1108bogus_field: "should fail"
1109"#;
1110 let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
1111 assert!(result.is_err(), "Should reject unknown fields");
1112 }
1113
1114 #[test]
1115 fn test_deny_unknown_fields_zstep() {
1116 let yaml = r#"
1117run: "echo hello"
1118bogus: "nope"
1119"#;
1120 let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
1121 assert!(result.is_err(), "Should reject unknown fields on ZStep");
1122 }
1123
1124 #[test]
1129 fn test_zimage_os_field_linux() {
1130 let yaml = r#"
1131base: "alpine:3.19"
1132os: linux
1133"#;
1134 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1135 assert_eq!(img.os, Some(ImageOs::Linux));
1136 assert_eq!(img.resolve_target_os(), Some(ImageOs::Linux));
1137 }
1138
1139 #[test]
1140 fn test_zimage_os_field_windows() {
1141 let yaml = r#"
1142base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1143os: windows
1144"#;
1145 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1146 assert_eq!(img.os, Some(ImageOs::Windows));
1147 assert_eq!(img.resolve_target_os(), Some(ImageOs::Windows));
1148 }
1149
1150 #[test]
1151 fn test_zimage_os_missing_is_none() {
1152 let yaml = r#"
1156base: "alpine:3.19"
1157"#;
1158 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1159 assert_eq!(img.os, None);
1160 assert_eq!(img.resolve_target_os(), None);
1161 }
1162
1163 #[test]
1164 fn test_zimage_os_wins_over_platform() {
1165 let yaml = r#"
1169base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1170platform: "linux/amd64"
1171os: windows
1172"#;
1173 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1174 assert_eq!(img.platform.as_deref(), Some("linux/amd64"));
1175 assert_eq!(img.os, Some(ImageOs::Windows));
1176 assert_eq!(
1177 img.resolve_target_os(),
1178 Some(ImageOs::Windows),
1179 "explicit os: must win over OS inferred from platform:"
1180 );
1181 }
1182
1183 #[test]
1184 fn test_zimage_os_inferred_from_platform() {
1185 let yaml = r#"
1187base: "alpine:3.19"
1188platform: "linux/arm64"
1189"#;
1190 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1191 assert_eq!(img.os, None);
1192 assert_eq!(
1193 img.resolve_target_os(),
1194 Some(ImageOs::Linux),
1195 "resolve_target_os must fall back to parsing platform:"
1196 );
1197 }
1198
1199 #[test]
1200 fn test_zimage_os_unknown_platform_ignored() {
1201 let yaml = r#"
1204base: "alpine:3.19"
1205platform: "amd64"
1206"#;
1207 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1208 assert_eq!(img.resolve_target_os(), None);
1209 }
1210
1211 #[test]
1212 fn test_zstage_os_field() {
1213 let yaml = r#"
1214stages:
1215 builder:
1216 base: "alpine:3.19"
1217 os: linux
1218 runtime:
1219 base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1220 os: windows
1221"#;
1222 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1223 let stages = img.stages.as_ref().unwrap();
1224 assert_eq!(stages["builder"].os, Some(ImageOs::Linux));
1225 assert_eq!(stages["runtime"].os, Some(ImageOs::Windows));
1226 }
1227
1228 #[test]
1229 fn test_zimage_os_field_darwin() {
1230 let yaml = r#"
1231base: "scratch"
1232os: darwin
1233"#;
1234 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1235 assert_eq!(img.os, Some(ImageOs::Darwin));
1236 assert_eq!(img.resolve_target_os(), Some(ImageOs::Darwin));
1237 }
1238
1239 #[test]
1240 fn test_zimage_os_inferred_from_platform_darwin() {
1241 let yaml = r#"
1243base: "scratch"
1244platform: "darwin/arm64"
1245"#;
1246 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1247 assert_eq!(img.os, None);
1248 assert_eq!(
1249 img.resolve_target_os(),
1250 Some(ImageOs::Darwin),
1251 "resolve_target_os must infer Darwin from a darwin/* platform:"
1252 );
1253 }
1254
1255 #[test]
1256 fn test_detect_image_os_from_binary() {
1257 use std::io::Write;
1258
1259 let dir = std::env::temp_dir().join(format!(
1262 "zlayer-detect-os-{}-{}",
1263 std::process::id(),
1264 std::time::SystemTime::now()
1265 .duration_since(std::time::UNIX_EPOCH)
1266 .map_or(0, |d| d.as_nanos())
1267 ));
1268 std::fs::create_dir_all(&dir).unwrap();
1269
1270 let make_image = |entrypoint: Option<&str>| {
1272 let mut steps = Vec::new();
1273 if entrypoint.is_some() {
1274 steps.push(ZStep {
1275 run: None,
1276 copy: Some(ZCopySources::Single("./task-executor".to_string())),
1277 add: None,
1278 env: None,
1279 workdir: None,
1280 user: None,
1281 to: Some("/usr/local/bin/task-executor".to_string()),
1282 from: None,
1283 owner: None,
1284 chmod: None,
1285 cache: Vec::new(),
1286 });
1287 }
1288 ZImage {
1289 version: None,
1290 runtime: None,
1291 base: Some("scratch".to_string()),
1292 build: None,
1293 steps,
1294 platform: None,
1295 os: None,
1296 isolation: None,
1297 stages: None,
1298 wasm: None,
1299 env: HashMap::new(),
1300 workdir: None,
1301 expose: None,
1302 cmd: None,
1303 entrypoint: entrypoint.map(|e| ZCommand::Exec(vec![e.to_string()])),
1304 user: None,
1305 labels: HashMap::new(),
1306 volumes: Vec::new(),
1307 healthcheck: None,
1308 stopsignal: None,
1309 args: HashMap::new(),
1310 }
1311 };
1312
1313 {
1315 let mut f = std::fs::File::create(dir.join("task-executor")).unwrap();
1316 f.write_all(&[0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00])
1317 .unwrap();
1318 f.flush().unwrap();
1319 let img = make_image(Some("/usr/local/bin/task-executor"));
1320 assert_eq!(
1321 detect_image_os_from_binary(&img, &dir),
1322 Some(ImageOs::Darwin),
1323 "Mach-O magic at the COPY source must classify as Darwin"
1324 );
1325 }
1326
1327 {
1329 let mut f = std::fs::File::create(dir.join("task-executor")).unwrap();
1330 f.write_all(&[0x7f, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00])
1331 .unwrap();
1332 f.flush().unwrap();
1333 let img = make_image(Some("/usr/local/bin/task-executor"));
1334 assert_eq!(
1335 detect_image_os_from_binary(&img, &dir),
1336 Some(ImageOs::Linux),
1337 "ELF magic at the COPY source must classify as Linux"
1338 );
1339 }
1340
1341 {
1343 let img = make_image(None);
1344 assert_eq!(
1345 detect_image_os_from_binary(&img, &dir),
1346 None,
1347 "an image with no entrypoint or cmd must yield None"
1348 );
1349 }
1350
1351 let _ = std::fs::remove_dir_all(&dir);
1352 }
1353
1354 #[test]
1359 fn test_zimage_isolation_vz() {
1360 use zlayer_types::spec::RuntimeIsolation as RI;
1361 let yaml = r#"
1362base: "alpine:3.19"
1363isolation: vz
1364"#;
1365 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1366 assert_eq!(img.isolation, Some(ZIsolation::Vz));
1367 assert_eq!(img.resolve_isolation(), Some(RI::Vz));
1368 }
1369
1370 #[test]
1371 fn test_zimage_isolation_vz_linux() {
1372 use zlayer_types::spec::RuntimeIsolation as RI;
1373 let yaml = r#"
1374base: "alpine:3.19"
1375isolation: vz-linux
1376"#;
1377 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1378 assert_eq!(img.isolation, Some(ZIsolation::VzLinux));
1379 assert_eq!(img.resolve_isolation(), Some(RI::VzLinux));
1380 }
1381
1382 #[test]
1383 fn test_zimage_isolation_native_is_sandbox() {
1384 use zlayer_types::spec::RuntimeIsolation as RI;
1385 let yaml = r#"
1386base: "alpine:3.19"
1387isolation: native
1388"#;
1389 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1390 assert_eq!(img.isolation, Some(ZIsolation::Native));
1391 assert_eq!(
1392 img.resolve_isolation(),
1393 Some(RI::Sandbox),
1394 "`native` is a friendly synonym for `sandbox`"
1395 );
1396 }
1397
1398 #[test]
1399 fn test_zimage_isolation_container_darwin_is_vz() {
1400 use zlayer_types::spec::RuntimeIsolation as RI;
1401 let yaml = r#"
1402base: "scratch"
1403os: darwin
1404isolation: container
1405"#;
1406 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1407 assert_eq!(img.isolation, Some(ZIsolation::Container));
1408 assert_eq!(
1409 img.resolve_isolation(),
1410 Some(RI::Vz),
1411 "`container` on a darwin target resolves to vz"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_zimage_isolation_container_default_is_vz_linux() {
1417 use zlayer_types::spec::RuntimeIsolation as RI;
1418 let yaml = r#"
1420base: "alpine:3.19"
1421isolation: container
1422"#;
1423 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1424 assert_eq!(img.isolation, Some(ZIsolation::Container));
1425 assert_eq!(img.resolve_isolation(), Some(RI::VzLinux));
1426 }
1427
1428 #[test]
1429 fn test_zimage_isolation_absent_is_none() {
1430 let yaml = r#"
1431base: "alpine:3.19"
1432"#;
1433 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1434 assert_eq!(img.isolation, None);
1435 assert_eq!(img.resolve_isolation(), None);
1436 }
1437
1438 #[test]
1439 fn test_zstage_isolation_field() {
1440 let yaml = r#"
1441stages:
1442 builder:
1443 base: "alpine:3.19"
1444 isolation: sandbox
1445 runtime:
1446 base: "alpine:3.19"
1447 isolation: vz-linux
1448"#;
1449 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1450 let stages = img.stages.as_ref().unwrap();
1451 assert_eq!(stages["builder"].isolation, Some(ZIsolation::Sandbox));
1452 assert_eq!(stages["runtime"].isolation, Some(ZIsolation::VzLinux));
1453 }
1454
1455 #[test]
1456 fn test_roundtrip_serialize() {
1457 let yaml = r#"
1458base: "alpine:3.19"
1459steps:
1460 - run: "echo hello"
1461 - copy: "."
1462 to: "/app"
1463cmd: "echo done"
1464"#;
1465 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1466 let serialized = serde_yaml::to_string(&img).unwrap();
1467 let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
1468 assert_eq!(img.base, img2.base);
1469 assert_eq!(img.steps.len(), img2.steps.len());
1470 }
1471}