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 pub fn context_dir(&self, base: &Path) -> PathBuf {
61 match self {
62 Self::Short(path) => base.join(path),
63 Self::Full { workdir, .. } => match workdir {
64 Some(dir) => base.join(dir),
65 None => base.to_path_buf(),
66 },
67 }
68 }
69
70 pub fn file(&self) -> Option<&str> {
72 match self {
73 Self::Short(_) => None,
74 Self::Full { file, .. } => file.as_deref(),
75 }
76 }
77
78 pub fn args(&self) -> HashMap<String, String> {
80 match self {
81 Self::Short(_) => HashMap::new(),
82 Self::Full { args, .. } => args.clone(),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct ZImage {
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub version: Option<String>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub runtime: Option<String>,
112
113 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub base: Option<String>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub build: Option<ZBuildContext>,
123
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub steps: Vec<ZStep>,
127
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub platform: Option<String>,
131
132 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub stages: Option<IndexMap<String, ZStage>>,
137
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub wasm: Option<ZWasmConfig>,
142
143 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
146 pub env: HashMap<String, String>,
147
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub workdir: Option<String>,
151
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub expose: Option<ZExpose>,
155
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub cmd: Option<ZCommand>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub entrypoint: Option<ZCommand>,
163
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub user: Option<String>,
167
168 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
170 pub labels: HashMap<String, String>,
171
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub volumes: Vec<String>,
175
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub healthcheck: Option<ZHealthcheck>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub stopsignal: Option<String>,
183
184 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
186 pub args: HashMap<String, String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct ZStage {
197 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub base: Option<String>,
201
202 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub build: Option<ZBuildContext>,
206
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub platform: Option<String>,
210
211 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
213 pub args: HashMap<String, String>,
214
215 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
217 pub env: HashMap<String, String>,
218
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub workdir: Option<String>,
222
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub steps: Vec<ZStep>,
226
227 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
229 pub labels: HashMap<String, String>,
230
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub expose: Option<ZExpose>,
234
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub user: Option<String>,
238
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub entrypoint: Option<ZCommand>,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub cmd: Option<ZCommand>,
246
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub volumes: Vec<String>,
250
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub healthcheck: Option<ZHealthcheck>,
254
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub stopsignal: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct ZStep {
272 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub run: Option<ZCommand>,
276
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub copy: Option<ZCopySources>,
280
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub add: Option<ZCopySources>,
284
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub env: Option<HashMap<String, String>>,
288
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub workdir: Option<String>,
292
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub user: Option<String>,
296
297 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub to: Option<String>,
301
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub from: Option<String>,
305
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub owner: Option<String>,
309
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub chmod: Option<String>,
313
314 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub cache: Vec<ZCacheMount>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct ZCacheMount {
329 pub target: String,
331
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub id: Option<String>,
335
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub sharing: Option<String>,
339
340 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
342 pub readonly: bool,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(untagged)]
365pub enum ZCommand {
366 Shell(String),
368 Exec(Vec<String>),
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
390#[serde(untagged)]
391pub enum ZCopySources {
392 Single(String),
394 Multiple(Vec<String>),
396}
397
398impl ZCopySources {
399 pub fn to_vec(&self) -> Vec<String> {
401 match self {
402 Self::Single(s) => vec![s.clone()],
403 Self::Multiple(v) => v.clone(),
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
428#[serde(untagged)]
429pub enum ZExpose {
430 Single(u16),
432 Multiple(Vec<ZPortSpec>),
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(untagged)]
452pub enum ZPortSpec {
453 Number(u16),
455 WithProtocol(String),
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
476#[serde(deny_unknown_fields)]
477pub struct ZHealthcheck {
478 pub cmd: ZCommand,
480
481 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub interval: Option<String>,
484
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub timeout: Option<String>,
488
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub start_period: Option<String>,
492
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub retries: Option<u32>,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
515#[serde(deny_unknown_fields)]
516pub struct ZWasmConfig {
517 #[serde(default = "default_wasm_target")]
519 pub target: String,
520
521 #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
523 pub optimize: bool,
524
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub language: Option<String>,
528
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub wit: Option<String>,
532
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub output: Option<String>,
536}
537
538fn default_wasm_target() -> String {
544 "preview2".to_string()
545}
546
547fn is_false(v: &bool) -> bool {
549 !v
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn test_runtime_mode_deserialize() {
558 let yaml = r#"
559runtime: node22
560cmd: "node server.js"
561"#;
562 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
563 assert_eq!(img.runtime.as_deref(), Some("node22"));
564 assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
565 }
566
567 #[test]
568 fn test_single_stage_deserialize() {
569 let yaml = r#"
570base: "alpine:3.19"
571steps:
572 - run: "apk add --no-cache curl"
573 - copy: "app.sh"
574 to: "/usr/local/bin/app.sh"
575 chmod: "755"
576 - workdir: "/app"
577env:
578 NODE_ENV: production
579expose: 8080
580cmd: ["./app.sh"]
581"#;
582 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
583 assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
584 assert_eq!(img.steps.len(), 3);
585 assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
586 assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
587 assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
588 }
589
590 #[test]
591 fn test_multi_stage_deserialize() {
592 let yaml = r#"
593stages:
594 builder:
595 base: "node:22-alpine"
596 workdir: "/src"
597 steps:
598 - copy: ["package.json", "package-lock.json"]
599 to: "./"
600 - run: "npm ci"
601 - copy: "."
602 to: "."
603 - run: "npm run build"
604 runtime:
605 base: "node:22-alpine"
606 workdir: "/app"
607 steps:
608 - copy: "dist"
609 from: builder
610 to: "/app"
611 cmd: ["node", "dist/index.js"]
612expose: 3000
613"#;
614 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
615 let stages = img.stages.as_ref().unwrap();
616 assert_eq!(stages.len(), 2);
617
618 let keys: Vec<&String> = stages.keys().collect();
620 assert_eq!(keys, vec!["builder", "runtime"]);
621
622 let builder = &stages["builder"];
623 assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
624 assert_eq!(builder.steps.len(), 4);
625
626 let runtime = &stages["runtime"];
627 assert_eq!(runtime.steps.len(), 1);
628 assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
629 }
630
631 #[test]
632 fn test_wasm_mode_deserialize() {
633 let yaml = r#"
634wasm:
635 target: preview2
636 optimize: true
637 language: rust
638 wit: "./wit"
639 output: "./output.wasm"
640"#;
641 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
642 let wasm = img.wasm.as_ref().unwrap();
643 assert_eq!(wasm.target, "preview2");
644 assert!(wasm.optimize);
645 assert_eq!(wasm.language.as_deref(), Some("rust"));
646 assert_eq!(wasm.wit.as_deref(), Some("./wit"));
647 assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
648 }
649
650 #[test]
651 fn test_wasm_defaults() {
652 let yaml = r#"
653wasm: {}
654"#;
655 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
656 let wasm = img.wasm.as_ref().unwrap();
657 assert_eq!(wasm.target, "preview2");
658 assert!(!wasm.optimize);
659 assert!(wasm.language.is_none());
660 }
661
662 #[test]
663 fn test_zcommand_shell() {
664 let yaml = r#""echo hello""#;
665 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
666 assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
667 }
668
669 #[test]
670 fn test_zcommand_exec() {
671 let yaml = r#"["echo", "hello"]"#;
672 let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
673 assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
674 }
675
676 #[test]
677 fn test_zcopy_sources_single() {
678 let yaml = r#""package.json""#;
679 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
680 assert_eq!(src.to_vec(), vec!["package.json"]);
681 }
682
683 #[test]
684 fn test_zcopy_sources_multiple() {
685 let yaml = r#"["package.json", "tsconfig.json"]"#;
686 let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
687 assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
688 }
689
690 #[test]
691 fn test_zexpose_single() {
692 let yaml = "8080";
693 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
694 assert!(matches!(exp, ZExpose::Single(8080)));
695 }
696
697 #[test]
698 fn test_zexpose_multiple() {
699 let yaml = r#"
700- 8080
701- "9090/udp"
702"#;
703 let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
704 if let ZExpose::Multiple(ports) = exp {
705 assert_eq!(ports.len(), 2);
706 assert!(matches!(ports[0], ZPortSpec::Number(8080)));
707 assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
708 } else {
709 panic!("Expected ZExpose::Multiple");
710 }
711 }
712
713 #[test]
714 fn test_healthcheck_deserialize() {
715 let yaml = r#"
716cmd: "curl -f http://localhost/ || exit 1"
717interval: "30s"
718timeout: "10s"
719start_period: "5s"
720retries: 3
721"#;
722 let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
723 assert!(matches!(hc.cmd, ZCommand::Shell(_)));
724 assert_eq!(hc.interval.as_deref(), Some("30s"));
725 assert_eq!(hc.timeout.as_deref(), Some("10s"));
726 assert_eq!(hc.start_period.as_deref(), Some("5s"));
727 assert_eq!(hc.retries, Some(3));
728 }
729
730 #[test]
731 fn test_cache_mount_deserialize() {
732 let yaml = r#"
733target: /var/cache/apt
734id: apt-cache
735sharing: shared
736readonly: false
737"#;
738 let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
739 assert_eq!(cm.target, "/var/cache/apt");
740 assert_eq!(cm.id.as_deref(), Some("apt-cache"));
741 assert_eq!(cm.sharing.as_deref(), Some("shared"));
742 assert!(!cm.readonly);
743 }
744
745 #[test]
746 fn test_step_with_cache_mounts() {
747 let yaml = r#"
748run: "apt-get update && apt-get install -y curl"
749cache:
750 - target: /var/cache/apt
751 id: apt-cache
752 sharing: shared
753 - target: /var/lib/apt
754 readonly: true
755"#;
756 let step: ZStep = serde_yaml::from_str(yaml).unwrap();
757 assert!(step.run.is_some());
758 assert_eq!(step.cache.len(), 2);
759 assert_eq!(step.cache[0].target, "/var/cache/apt");
760 assert!(step.cache[1].readonly);
761 }
762
763 #[test]
764 fn test_deny_unknown_fields_zimage() {
765 let yaml = r#"
766base: "alpine:3.19"
767bogus_field: "should fail"
768"#;
769 let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
770 assert!(result.is_err(), "Should reject unknown fields");
771 }
772
773 #[test]
774 fn test_deny_unknown_fields_zstep() {
775 let yaml = r#"
776run: "echo hello"
777bogus: "nope"
778"#;
779 let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
780 assert!(result.is_err(), "Should reject unknown fields on ZStep");
781 }
782
783 #[test]
784 fn test_roundtrip_serialize() {
785 let yaml = r#"
786base: "alpine:3.19"
787steps:
788 - run: "echo hello"
789 - copy: "."
790 to: "/app"
791cmd: "echo done"
792"#;
793 let img: ZImage = serde_yaml::from_str(yaml).unwrap();
794 let serialized = serde_yaml::to_string(&img).unwrap();
795 let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
796 assert_eq!(img.base, img2.base);
797 assert_eq!(img.steps.len(), img2.steps.len());
798 }
799}