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