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)]
519#[serde(deny_unknown_fields)]
520pub struct ZWasmConfig {
521 #[serde(default = "default_wasm_target")]
523 pub target: String,
524
525 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
527 pub optimize: bool,
528
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub language: Option<String>,
532
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub wit: Option<String>,
536
537 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub output: Option<String>,
540}
541
542fn default_wasm_target() -> String {
548 "preview2".to_string()
549}
550
551#[allow(clippy::trivially_copy_pass_by_ref)]
553fn is_false(v: &bool) -> bool {
554 !v
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn test_runtime_mode_deserialize() {
563 let yaml = r#"
564runtime: node22
565cmd: "node server.js"
566"#;
567 let img: ZImage = serde_yml::from_str(yaml).unwrap();
568 assert_eq!(img.runtime.as_deref(), Some("node22"));
569 assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
570 }
571
572 #[test]
573 fn test_single_stage_deserialize() {
574 let yaml = r#"
575base: "alpine:3.19"
576steps:
577 - run: "apk add --no-cache curl"
578 - copy: "app.sh"
579 to: "/usr/local/bin/app.sh"
580 chmod: "755"
581 - workdir: "/app"
582env:
583 NODE_ENV: production
584expose: 8080
585cmd: ["./app.sh"]
586"#;
587 let img: ZImage = serde_yml::from_str(yaml).unwrap();
588 assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
589 assert_eq!(img.steps.len(), 3);
590 assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
591 assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
592 assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
593 }
594
595 #[test]
596 fn test_multi_stage_deserialize() {
597 let yaml = r#"
598stages:
599 builder:
600 base: "node:22-alpine"
601 workdir: "/src"
602 steps:
603 - copy: ["package.json", "package-lock.json"]
604 to: "./"
605 - run: "npm ci"
606 - copy: "."
607 to: "."
608 - run: "npm run build"
609 runtime:
610 base: "node:22-alpine"
611 workdir: "/app"
612 steps:
613 - copy: "dist"
614 from: builder
615 to: "/app"
616 cmd: ["node", "dist/index.js"]
617expose: 3000
618"#;
619 let img: ZImage = serde_yml::from_str(yaml).unwrap();
620 let stages = img.stages.as_ref().unwrap();
621 assert_eq!(stages.len(), 2);
622
623 let keys: Vec<&String> = stages.keys().collect();
625 assert_eq!(keys, vec!["builder", "runtime"]);
626
627 let builder = &stages["builder"];
628 assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
629 assert_eq!(builder.steps.len(), 4);
630
631 let runtime = &stages["runtime"];
632 assert_eq!(runtime.steps.len(), 1);
633 assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
634 }
635
636 #[test]
637 fn test_wasm_mode_deserialize() {
638 let yaml = r#"
639wasm:
640 target: preview2
641 optimize: true
642 language: rust
643 wit: "./wit"
644 output: "./output.wasm"
645"#;
646 let img: ZImage = serde_yml::from_str(yaml).unwrap();
647 let wasm = img.wasm.as_ref().unwrap();
648 assert_eq!(wasm.target, "preview2");
649 assert!(wasm.optimize);
650 assert_eq!(wasm.language.as_deref(), Some("rust"));
651 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
652 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
653 }
654
655 #[test]
656 fn test_wasm_defaults() {
657 let yaml = r#"
658wasm: {}
659"#;
660 let img: ZImage = serde_yml::from_str(yaml).unwrap();
661 let wasm = img.wasm.as_ref().unwrap();
662 assert_eq!(wasm.target, "preview2");
663 assert!(!wasm.optimize);
664 assert!(wasm.language.is_none());
665 }
666
667 #[test]
668 fn test_zcommand_shell() {
669 let yaml = r#""echo hello""#;
670 let cmd: ZCommand = serde_yml::from_str(yaml).unwrap();
671 assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
672 }
673
674 #[test]
675 fn test_zcommand_exec() {
676 let yaml = r#"["echo", "hello"]"#;
677 let cmd: ZCommand = serde_yml::from_str(yaml).unwrap();
678 assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
679 }
680
681 #[test]
682 fn test_zcopy_sources_single() {
683 let yaml = r#""package.json""#;
684 let src: ZCopySources = serde_yml::from_str(yaml).unwrap();
685 assert_eq!(src.to_vec(), vec!["package.json"]);
686 }
687
688 #[test]
689 fn test_zcopy_sources_multiple() {
690 let yaml = r#"["package.json", "tsconfig.json"]"#;
691 let src: ZCopySources = serde_yml::from_str(yaml).unwrap();
692 assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
693 }
694
695 #[test]
696 fn test_zexpose_single() {
697 let yaml = "8080";
698 let exp: ZExpose = serde_yml::from_str(yaml).unwrap();
699 assert!(matches!(exp, ZExpose::Single(8080)));
700 }
701
702 #[test]
703 fn test_zexpose_multiple() {
704 let yaml = r#"
705- 8080
706- "9090/udp"
707"#;
708 let exp: ZExpose = serde_yml::from_str(yaml).unwrap();
709 if let ZExpose::Multiple(ports) = exp {
710 assert_eq!(ports.len(), 2);
711 assert!(matches!(ports[0], ZPortSpec::Number(8080)));
712 assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
713 } else {
714 panic!("Expected ZExpose::Multiple");
715 }
716 }
717
718 #[test]
719 fn test_healthcheck_deserialize() {
720 let yaml = r#"
721cmd: "curl -f http://localhost/ || exit 1"
722interval: "30s"
723timeout: "10s"
724start_period: "5s"
725retries: 3
726"#;
727 let hc: ZHealthcheck = serde_yml::from_str(yaml).unwrap();
728 assert!(matches!(hc.cmd, ZCommand::Shell(_)));
729 assert_eq!(hc.interval.as_deref(), Some("30s"));
730 assert_eq!(hc.timeout.as_deref(), Some("10s"));
731 assert_eq!(hc.start_period.as_deref(), Some("5s"));
732 assert_eq!(hc.retries, Some(3));
733 }
734
735 #[test]
736 fn test_cache_mount_deserialize() {
737 let yaml = r#"
738target: /var/cache/apt
739id: apt-cache
740sharing: shared
741readonly: false
742"#;
743 let cm: ZCacheMount = serde_yml::from_str(yaml).unwrap();
744 assert_eq!(cm.target, "/var/cache/apt");
745 assert_eq!(cm.id.as_deref(), Some("apt-cache"));
746 assert_eq!(cm.sharing.as_deref(), Some("shared"));
747 assert!(!cm.readonly);
748 }
749
750 #[test]
751 fn test_step_with_cache_mounts() {
752 let yaml = r#"
753run: "apt-get update && apt-get install -y curl"
754cache:
755 - target: /var/cache/apt
756 id: apt-cache
757 sharing: shared
758 - target: /var/lib/apt
759 readonly: true
760"#;
761 let step: ZStep = serde_yml::from_str(yaml).unwrap();
762 assert!(step.run.is_some());
763 assert_eq!(step.cache.len(), 2);
764 assert_eq!(step.cache[0].target, "/var/cache/apt");
765 assert!(step.cache[1].readonly);
766 }
767
768 #[test]
769 fn test_deny_unknown_fields_zimage() {
770 let yaml = r#"
771base: "alpine:3.19"
772bogus_field: "should fail"
773"#;
774 let result: Result<ZImage, _> = serde_yml::from_str(yaml);
775 assert!(result.is_err(), "Should reject unknown fields");
776 }
777
778 #[test]
779 fn test_deny_unknown_fields_zstep() {
780 let yaml = r#"
781run: "echo hello"
782bogus: "nope"
783"#;
784 let result: Result<ZStep, _> = serde_yml::from_str(yaml);
785 assert!(result.is_err(), "Should reject unknown fields on ZStep");
786 }
787
788 #[test]
789 fn test_roundtrip_serialize() {
790 let yaml = r#"
791base: "alpine:3.19"
792steps:
793 - run: "echo hello"
794 - copy: "."
795 to: "/app"
796cmd: "echo done"
797"#;
798 let img: ZImage = serde_yml::from_str(yaml).unwrap();
799 let serialized = serde_yml::to_string(&img).unwrap();
800 let img2: ZImage = serde_yml::from_str(&serialized).unwrap();
801 assert_eq!(img.base, img2.base);
802 assert_eq!(img.steps.len(), img2.steps.len());
803 }
804}