Skip to main content

zlayer_builder/zimage/
types.rs

1//! `ZImagefile` types - YAML-based image build format
2//!
3//! This module defines all serde-deserializable types for the `ZImagefile` format,
4//! an alternative to Dockerfiles using YAML syntax. The format supports four
5//! mutually exclusive build modes:
6//!
7//! 1. **Runtime template** - shorthand like `runtime: node22`
8//! 2. **Single-stage** - `base:` or `build:` + `steps:` at top level
9//! 3. **Multi-stage** - `stages:` map (`IndexMap` for insertion order, last = output)
10//! 4. **WASM** - `wasm:` configuration for WebAssembly builds
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18// ---------------------------------------------------------------------------
19// Build context (for `build:` directive)
20// ---------------------------------------------------------------------------
21
22/// Build context for building a base image from a local Dockerfile or `ZImagefile`.
23///
24/// Supports two forms:
25///
26/// ```yaml
27/// # Short form: just a path (defaults to auto-detecting build file)
28/// build: "."
29///
30/// # Long form: explicit configuration
31/// build:
32///   context: "./subdir"     # or use `workdir:` (context is an alias)
33///   file: "ZImagefile.prod" # specific build file
34///   args:
35///     RUST_VERSION: "1.90"
36/// ```
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(untagged)]
39pub enum ZBuildContext {
40    /// Short form: just a path to the build context directory.
41    Short(String),
42    /// Long form: explicit build configuration.
43    Full {
44        /// Build context directory. Defaults to the current working directory.
45        /// `context` is accepted as an alias for Docker Compose compatibility.
46        #[serde(alias = "context", default)]
47        workdir: Option<String>,
48        /// Path to the build file (Dockerfile or `ZImagefile`).
49        /// Auto-detected if omitted (prefers `ZImagefile` over Dockerfile).
50        #[serde(default, skip_serializing_if = "Option::is_none")]
51        file: Option<String>,
52        /// Build arguments passed to the nested build.
53        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
54        args: HashMap<String, String>,
55    },
56}
57
58impl ZBuildContext {
59    /// Resolve the build context directory relative to a base path.
60    #[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    /// Get the explicit build file path, if specified.
72    #[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    /// Get build arguments.
81    #[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// ---------------------------------------------------------------------------
91// Top-level ZImage
92// ---------------------------------------------------------------------------
93
94/// Top-level `ZImagefile` representation.
95///
96/// Exactly one of the four mode fields must be set:
97/// - `runtime` for runtime template shorthand
98/// - `base` + `steps` for single-stage builds
99/// - `stages` for multi-stage builds
100/// - `wasm` for WebAssembly component builds
101///
102/// Common image metadata fields (env, workdir, expose, cmd, etc.) apply to
103/// the final output image regardless of mode.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct ZImage {
107    /// `ZImagefile` format version (currently must be "1")
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub version: Option<String>,
110
111    // -- Mode 1: runtime template shorthand --
112    /// Runtime template name, e.g. "node22", "python313", "rust"
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub runtime: Option<String>,
115
116    // -- Mode 2: single-stage --
117    /// Base image for single-stage builds (e.g. "alpine:3.19").
118    /// Mutually exclusive with `build`.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub base: Option<String>,
121
122    /// Build a base image from a local Dockerfile/ZImagefile context.
123    /// Mutually exclusive with `base`.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub build: Option<ZBuildContext>,
126
127    /// Build steps for single-stage mode
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub steps: Vec<ZStep>,
130
131    /// Target platform for single-stage mode (e.g. "linux/amd64")
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub platform: Option<String>,
134
135    // -- Mode 3: multi-stage --
136    /// Named stages for multi-stage builds. Insertion order is preserved;
137    /// the last stage is the output image.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub stages: Option<IndexMap<String, ZStage>>,
140
141    // -- Mode 4: WASM --
142    /// WebAssembly build configuration
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub wasm: Option<ZWasmConfig>,
145
146    // -- Common image metadata --
147    /// Environment variables applied to the final image
148    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
149    pub env: HashMap<String, String>,
150
151    /// Working directory for the final image
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub workdir: Option<String>,
154
155    /// Ports to expose
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub expose: Option<ZExpose>,
158
159    /// Default command (CMD equivalent)
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub cmd: Option<ZCommand>,
162
163    /// Entrypoint command
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub entrypoint: Option<ZCommand>,
166
167    /// User to run as in the final image
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub user: Option<String>,
170
171    /// Image labels / metadata
172    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
173    pub labels: HashMap<String, String>,
174
175    /// Volume mount points
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub volumes: Vec<String>,
178
179    /// Healthcheck configuration
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub healthcheck: Option<ZHealthcheck>,
182
183    /// Signal to send when stopping the container
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub stopsignal: Option<String>,
186
187    /// Build arguments (name -> default value)
188    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
189    pub args: HashMap<String, String>,
190}
191
192// ---------------------------------------------------------------------------
193// Stage
194// ---------------------------------------------------------------------------
195
196/// A single build stage in a multi-stage `ZImagefile`.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct ZStage {
200    /// Base image for this stage (e.g. "node:22-alpine").
201    /// Mutually exclusive with `build`. One of `base` or `build` must be set.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub base: Option<String>,
204
205    /// Build a base image from a local Dockerfile/ZImagefile context.
206    /// Mutually exclusive with `base`. One of `base` or `build` must be set.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub build: Option<ZBuildContext>,
209
210    /// Target platform override (e.g. "linux/arm64")
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub platform: Option<String>,
213
214    /// Build arguments scoped to this stage
215    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
216    pub args: HashMap<String, String>,
217
218    /// Environment variables for this stage
219    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
220    pub env: HashMap<String, String>,
221
222    /// Working directory for this stage
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub workdir: Option<String>,
225
226    /// Ordered build steps
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub steps: Vec<ZStep>,
229
230    /// Labels for this stage
231    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
232    pub labels: HashMap<String, String>,
233
234    /// Ports to expose
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub expose: Option<ZExpose>,
237
238    /// User to run as
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub user: Option<String>,
241
242    /// Entrypoint command
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub entrypoint: Option<ZCommand>,
245
246    /// Default command
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub cmd: Option<ZCommand>,
249
250    /// Volume mount points
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub volumes: Vec<String>,
253
254    /// Healthcheck configuration
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub healthcheck: Option<ZHealthcheck>,
257
258    /// Signal to send when stopping the container
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub stopsignal: Option<String>,
261}
262
263// ---------------------------------------------------------------------------
264// Step
265// ---------------------------------------------------------------------------
266
267/// A single build instruction within a stage.
268///
269/// Exactly one of the action fields (`run`, `copy`, `add`, `env`, `workdir`,
270/// `user`) should be set. The remaining fields are modifiers that apply to
271/// the chosen action.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct ZStep {
275    // -- Mutually exclusive action fields --
276    /// Shell command or exec-form command to run
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub run: Option<ZCommand>,
279
280    /// Source path(s) to copy into the image
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub copy: Option<ZCopySources>,
283
284    /// Source path(s) to add (supports URLs and auto-extraction)
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub add: Option<ZCopySources>,
287
288    /// Environment variables to set
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub env: Option<HashMap<String, String>>,
291
292    /// Change working directory
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub workdir: Option<String>,
295
296    /// Change user
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub user: Option<String>,
299
300    // -- Shared modifier fields --
301    /// Destination path (for copy/add actions)
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub to: Option<String>,
304
305    /// Source stage name for cross-stage copy (replaces `--from`)
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub from: Option<String>,
308
309    /// File ownership (replaces `--chown`)
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub owner: Option<String>,
312
313    /// File permissions (replaces `--chmod`)
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub chmod: Option<String>,
316
317    /// Cache mounts for RUN steps
318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
319    pub cache: Vec<ZCacheMount>,
320}
321
322// ---------------------------------------------------------------------------
323// Cache mount
324// ---------------------------------------------------------------------------
325
326/// A cache mount specification for RUN steps.
327///
328/// Maps to `--mount=type=cache` in Dockerfile/buildah syntax.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct ZCacheMount {
332    /// Target path inside the container where the cache is mounted
333    pub target: String,
334
335    /// Cache identifier (shared across builds with the same id)
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub id: Option<String>,
338
339    /// Sharing mode: "locked", "shared", or "private"
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub sharing: Option<String>,
342
343    /// Whether the mount is read-only
344    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
345    pub readonly: bool,
346}
347
348// ---------------------------------------------------------------------------
349// Command (shell string or exec array)
350// ---------------------------------------------------------------------------
351
352/// A command that can be specified as either a shell string or an exec-form
353/// array of strings.
354///
355/// # YAML Examples
356///
357/// Shell form:
358/// ```yaml
359/// run: "apt-get update && apt-get install -y curl"
360/// ```
361///
362/// Exec form:
363/// ```yaml
364/// cmd: ["node", "server.js"]
365/// ```
366#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(untagged)]
368pub enum ZCommand {
369    /// Shell form - passed to `/bin/sh -c`
370    Shell(String),
371    /// Exec form - executed directly
372    Exec(Vec<String>),
373}
374
375// ---------------------------------------------------------------------------
376// Copy sources
377// ---------------------------------------------------------------------------
378
379/// Source specification for copy/add steps. Can be a single path or multiple.
380///
381/// # YAML Examples
382///
383/// Single source:
384/// ```yaml
385/// copy: "package.json"
386/// ```
387///
388/// Multiple sources:
389/// ```yaml
390/// copy: ["package.json", "package-lock.json"]
391/// ```
392#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(untagged)]
394pub enum ZCopySources {
395    /// A single source path
396    Single(String),
397    /// Multiple source paths
398    Multiple(Vec<String>),
399}
400
401impl ZCopySources {
402    /// Convert to a vector of source paths regardless of variant.
403    #[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// ---------------------------------------------------------------------------
413// Expose
414// ---------------------------------------------------------------------------
415
416/// Port exposure specification. Can be a single port or multiple port specs.
417///
418/// # YAML Examples
419///
420/// Single port:
421/// ```yaml
422/// expose: 8080
423/// ```
424///
425/// Multiple ports with optional protocol:
426/// ```yaml
427/// expose:
428///   - 8080
429///   - "9090/udp"
430/// ```
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(untagged)]
433pub enum ZExpose {
434    /// A single port number
435    Single(u16),
436    /// Multiple port specifications
437    Multiple(Vec<ZPortSpec>),
438}
439
440// ---------------------------------------------------------------------------
441// Port spec
442// ---------------------------------------------------------------------------
443
444/// A single port specification, either a bare port number or a port with
445/// protocol suffix.
446///
447/// # YAML Examples
448///
449/// ```yaml
450/// - 8080        # bare number, defaults to TCP
451/// - "8080/tcp"  # explicit TCP
452/// - "53/udp"    # explicit UDP
453/// ```
454#[derive(Debug, Clone, Serialize, Deserialize)]
455#[serde(untagged)]
456pub enum ZPortSpec {
457    /// Bare port number (defaults to TCP)
458    Number(u16),
459    /// Port with protocol, e.g. "8080/tcp" or "53/udp"
460    WithProtocol(String),
461}
462
463// ---------------------------------------------------------------------------
464// Healthcheck
465// ---------------------------------------------------------------------------
466
467/// Healthcheck configuration for the container.
468///
469/// # YAML Example
470///
471/// ```yaml
472/// healthcheck:
473///   cmd: "curl -f http://localhost:8080/health || exit 1"
474///   interval: "30s"
475///   timeout: "10s"
476///   start_period: "5s"
477///   retries: 3
478/// ```
479#[derive(Debug, Clone, Serialize, Deserialize)]
480#[serde(deny_unknown_fields)]
481pub struct ZHealthcheck {
482    /// Command to run for the health check
483    pub cmd: ZCommand,
484
485    /// Interval between health checks (e.g. "30s", "1m")
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub interval: Option<String>,
488
489    /// Timeout for each health check (e.g. "10s")
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub timeout: Option<String>,
492
493    /// Grace period before first check (e.g. "5s")
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub start_period: Option<String>,
496
497    /// Number of consecutive failures before unhealthy
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub retries: Option<u32>,
500}
501
502// ---------------------------------------------------------------------------
503// WASM config
504// ---------------------------------------------------------------------------
505
506/// WebAssembly build configuration for WASM mode.
507///
508/// # YAML Example
509///
510/// ```yaml
511/// wasm:
512///   target: "preview2"
513///   optimize: true
514///   language: "rust"
515///   wit: "./wit"
516///   output: "./output.wasm"
517/// ```
518#[derive(Debug, Clone, Serialize, Deserialize)]
519#[serde(deny_unknown_fields)]
520pub struct ZWasmConfig {
521    /// WASI target version (default: "preview2")
522    #[serde(default = "default_wasm_target")]
523    pub target: String,
524
525    /// Whether to run wasm-opt on the output
526    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
527    pub optimize: bool,
528
529    /// Source language (auto-detected if omitted)
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub language: Option<String>,
532
533    /// Path to WIT definitions
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub wit: Option<String>,
536
537    /// Output path for the compiled WASM file
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub output: Option<String>,
540}
541
542// ---------------------------------------------------------------------------
543// Helpers
544// ---------------------------------------------------------------------------
545
546/// Default WASM target version.
547fn default_wasm_target() -> String {
548    "preview2".to_string()
549}
550
551/// Helper for `skip_serializing_if` on boolean fields.
552#[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        // Verify insertion order is preserved
624        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}