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///   opt_level: "Oz"
515///   language: "rust"
516///   world: "zlayer-http-handler"
517///   wit: "./wit"
518///   output: "./output.wasm"
519///   features: [json, metrics]
520///   build_args:
521///     CARGO_PROFILE_RELEASE_LTO: "true"
522///   pre_build:
523///     - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
524///   post_build:
525///     - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
526///   adapter: "./wasi_snapshot_preview1.reactor.wasm"
527/// ```
528#[derive(Debug, Clone, Serialize, Deserialize)]
529#[serde(deny_unknown_fields)]
530pub struct ZWasmConfig {
531    /// WASI target version: "preview1" or "preview2" (default: "preview2")
532    #[serde(default = "default_wasm_target")]
533    pub target: String,
534
535    /// Whether to run wasm-opt on the output
536    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
537    pub optimize: bool,
538
539    /// Optimization level for wasm-opt: "O", "Os", "Oz", "O2", "O3" (default: "Oz")
540    #[serde(
541        default = "default_wasm_opt_level",
542        skip_serializing_if = "Option::is_none"
543    )]
544    pub opt_level: Option<String>,
545
546    /// Source language (auto-detected if omitted): rust, go, python, typescript, assemblyscript, c, zig
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub language: Option<String>,
549
550    /// Path to WIT definitions (default: "./wit")
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub wit: Option<String>,
553
554    /// Target WIT world name (e.g., "zlayer-http-handler", "zlayer-plugin", "zlayer-transformer",
555    /// "zlayer-authenticator", "zlayer-rate-limiter", "zlayer-middleware", "zlayer-router")
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub world: Option<String>,
558
559    /// Output path for the compiled WASM file
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub output: Option<String>,
562
563    /// Language-specific features to enable during build
564    #[serde(default, skip_serializing_if = "Vec::is_empty")]
565    pub features: Vec<String>,
566
567    /// Additional build arguments (language-specific, e.g. `CARGO_PROFILE_RELEASE_LTO`)
568    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
569    pub build_args: HashMap<String, String>,
570
571    /// Pre-build commands to run before compilation (e.g., WIT binding generation for Go)
572    #[serde(default, skip_serializing_if = "Vec::is_empty")]
573    pub pre_build: Vec<ZCommand>,
574
575    /// Post-build commands to run after compilation (before optimization)
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    pub post_build: Vec<ZCommand>,
578
579    /// Component adapter path for WASI preview1 -> preview2 lifting
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub adapter: Option<String>,
582
583    /// When false, skip OCI artifact packaging and push — only produce the raw .wasm.
584    /// Compilation and caching are unaffected. Default: true.
585    #[serde(
586        default = "default_wasm_oci",
587        skip_serializing_if = "crate::zimage::types::is_true"
588    )]
589    pub oci: bool,
590}
591
592// ---------------------------------------------------------------------------
593// Helpers
594// ---------------------------------------------------------------------------
595
596/// Default WASM target version.
597fn default_wasm_target() -> String {
598    "preview2".to_string()
599}
600
601/// Default WASM optimization level.
602#[allow(clippy::unnecessary_wraps)]
603fn default_wasm_opt_level() -> Option<String> {
604    Some("Oz".to_string())
605}
606
607/// Helper for `skip_serializing_if` on boolean fields.
608#[allow(clippy::trivially_copy_pass_by_ref)]
609fn is_false(v: &bool) -> bool {
610    !v
611}
612
613/// Helper for `skip_serializing_if` on boolean fields whose default is `true`.
614#[allow(clippy::trivially_copy_pass_by_ref)]
615pub(crate) fn is_true(v: &bool) -> bool {
616    *v
617}
618
619/// Default for `wasm.oci`: produce the OCI layout alongside the raw `.wasm`.
620fn default_wasm_oci() -> bool {
621    true
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn test_runtime_mode_deserialize() {
630        let yaml = r#"
631runtime: node22
632cmd: "node server.js"
633"#;
634        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
635        assert_eq!(img.runtime.as_deref(), Some("node22"));
636        assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
637    }
638
639    #[test]
640    fn test_single_stage_deserialize() {
641        let yaml = r#"
642base: "alpine:3.19"
643steps:
644  - run: "apk add --no-cache curl"
645  - copy: "app.sh"
646    to: "/usr/local/bin/app.sh"
647    chmod: "755"
648  - workdir: "/app"
649env:
650  NODE_ENV: production
651expose: 8080
652cmd: ["./app.sh"]
653"#;
654        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
655        assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
656        assert_eq!(img.steps.len(), 3);
657        assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
658        assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
659        assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
660    }
661
662    #[test]
663    fn test_multi_stage_deserialize() {
664        let yaml = r#"
665stages:
666  builder:
667    base: "node:22-alpine"
668    workdir: "/src"
669    steps:
670      - copy: ["package.json", "package-lock.json"]
671        to: "./"
672      - run: "npm ci"
673      - copy: "."
674        to: "."
675      - run: "npm run build"
676  runtime:
677    base: "node:22-alpine"
678    workdir: "/app"
679    steps:
680      - copy: "dist"
681        from: builder
682        to: "/app"
683    cmd: ["node", "dist/index.js"]
684expose: 3000
685"#;
686        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
687        let stages = img.stages.as_ref().unwrap();
688        assert_eq!(stages.len(), 2);
689
690        // Verify insertion order is preserved
691        let keys: Vec<&String> = stages.keys().collect();
692        assert_eq!(keys, vec!["builder", "runtime"]);
693
694        let builder = &stages["builder"];
695        assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
696        assert_eq!(builder.steps.len(), 4);
697
698        let runtime = &stages["runtime"];
699        assert_eq!(runtime.steps.len(), 1);
700        assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
701    }
702
703    #[test]
704    fn test_wasm_mode_deserialize() {
705        let yaml = r#"
706wasm:
707  target: preview2
708  optimize: true
709  language: rust
710  wit: "./wit"
711  output: "./output.wasm"
712"#;
713        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
714        let wasm = img.wasm.as_ref().unwrap();
715        assert_eq!(wasm.target, "preview2");
716        assert!(wasm.optimize);
717        assert_eq!(wasm.language.as_deref(), Some("rust"));
718        assert_eq!(wasm.wit.as_deref(), Some("./wit"));
719        assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
720    }
721
722    #[test]
723    fn test_wasm_defaults() {
724        let yaml = r"
725wasm: {}
726";
727        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
728        let wasm = img.wasm.as_ref().unwrap();
729        assert_eq!(wasm.target, "preview2");
730        assert!(!wasm.optimize);
731        assert!(wasm.language.is_none());
732        assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
733        assert!(wasm.world.is_none());
734        assert!(wasm.features.is_empty());
735        assert!(wasm.build_args.is_empty());
736        assert!(wasm.pre_build.is_empty());
737        assert!(wasm.post_build.is_empty());
738        assert!(wasm.adapter.is_none());
739        assert!(
740            wasm.oci,
741            "default ZWasmConfig.oci must be true so OCI packaging still happens unless explicitly opted out"
742        );
743    }
744
745    #[test]
746    fn test_wasm_oci_opt_out() {
747        let yaml = r"
748wasm:
749  oci: false
750";
751        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
752        let wasm = img.wasm.as_ref().unwrap();
753        assert!(
754            !wasm.oci,
755            "wasm.oci: false must deserialize to ZWasmConfig.oci == false"
756        );
757    }
758
759    #[test]
760    fn test_wasm_full_config() {
761        let yaml = r#"
762wasm:
763  target: "preview2"
764  optimize: true
765  opt_level: "O3"
766  language: "rust"
767  world: "zlayer-http-handler"
768  wit: "./wit"
769  output: "./output.wasm"
770  features:
771    - json
772    - metrics
773  build_args:
774    CARGO_PROFILE_RELEASE_LTO: "true"
775    RUSTFLAGS: "-C target-feature=+simd128"
776  pre_build:
777    - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
778  post_build:
779    - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
780  adapter: "./wasi_snapshot_preview1.reactor.wasm"
781"#;
782        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
783        let wasm = img.wasm.as_ref().unwrap();
784        assert_eq!(wasm.target, "preview2");
785        assert!(wasm.optimize);
786        assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
787        assert_eq!(wasm.language.as_deref(), Some("rust"));
788        assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
789        assert_eq!(wasm.wit.as_deref(), Some("./wit"));
790        assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
791        assert_eq!(wasm.features, vec!["json", "metrics"]);
792        assert_eq!(
793            wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
794            "true"
795        );
796        assert_eq!(
797            wasm.build_args.get("RUSTFLAGS").unwrap(),
798            "-C target-feature=+simd128"
799        );
800        assert_eq!(wasm.pre_build.len(), 1);
801        assert_eq!(wasm.post_build.len(), 1);
802        assert_eq!(
803            wasm.adapter.as_deref(),
804            Some("./wasi_snapshot_preview1.reactor.wasm")
805        );
806    }
807
808    #[test]
809    fn test_zcommand_shell() {
810        let yaml = r#""echo hello""#;
811        let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
812        assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
813    }
814
815    #[test]
816    fn test_zcommand_exec() {
817        let yaml = r#"["echo", "hello"]"#;
818        let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
819        assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
820    }
821
822    #[test]
823    fn test_zcopy_sources_single() {
824        let yaml = r#""package.json""#;
825        let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
826        assert_eq!(src.to_vec(), vec!["package.json"]);
827    }
828
829    #[test]
830    fn test_zcopy_sources_multiple() {
831        let yaml = r#"["package.json", "tsconfig.json"]"#;
832        let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
833        assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
834    }
835
836    #[test]
837    fn test_zexpose_single() {
838        let yaml = "8080";
839        let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
840        assert!(matches!(exp, ZExpose::Single(8080)));
841    }
842
843    #[test]
844    fn test_zexpose_multiple() {
845        let yaml = r#"
846- 8080
847- "9090/udp"
848"#;
849        let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
850        if let ZExpose::Multiple(ports) = exp {
851            assert_eq!(ports.len(), 2);
852            assert!(matches!(ports[0], ZPortSpec::Number(8080)));
853            assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
854        } else {
855            panic!("Expected ZExpose::Multiple");
856        }
857    }
858
859    #[test]
860    fn test_healthcheck_deserialize() {
861        let yaml = r#"
862cmd: "curl -f http://localhost/ || exit 1"
863interval: "30s"
864timeout: "10s"
865start_period: "5s"
866retries: 3
867"#;
868        let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
869        assert!(matches!(hc.cmd, ZCommand::Shell(_)));
870        assert_eq!(hc.interval.as_deref(), Some("30s"));
871        assert_eq!(hc.timeout.as_deref(), Some("10s"));
872        assert_eq!(hc.start_period.as_deref(), Some("5s"));
873        assert_eq!(hc.retries, Some(3));
874    }
875
876    #[test]
877    fn test_cache_mount_deserialize() {
878        let yaml = r"
879target: /var/cache/apt
880id: apt-cache
881sharing: shared
882readonly: false
883";
884        let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
885        assert_eq!(cm.target, "/var/cache/apt");
886        assert_eq!(cm.id.as_deref(), Some("apt-cache"));
887        assert_eq!(cm.sharing.as_deref(), Some("shared"));
888        assert!(!cm.readonly);
889    }
890
891    #[test]
892    fn test_step_with_cache_mounts() {
893        let yaml = r#"
894run: "apt-get update && apt-get install -y curl"
895cache:
896  - target: /var/cache/apt
897    id: apt-cache
898    sharing: shared
899  - target: /var/lib/apt
900    readonly: true
901"#;
902        let step: ZStep = serde_yaml::from_str(yaml).unwrap();
903        assert!(step.run.is_some());
904        assert_eq!(step.cache.len(), 2);
905        assert_eq!(step.cache[0].target, "/var/cache/apt");
906        assert!(step.cache[1].readonly);
907    }
908
909    #[test]
910    fn test_deny_unknown_fields_zimage() {
911        let yaml = r#"
912base: "alpine:3.19"
913bogus_field: "should fail"
914"#;
915        let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
916        assert!(result.is_err(), "Should reject unknown fields");
917    }
918
919    #[test]
920    fn test_deny_unknown_fields_zstep() {
921        let yaml = r#"
922run: "echo hello"
923bogus: "nope"
924"#;
925        let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
926        assert!(result.is_err(), "Should reject unknown fields on ZStep");
927    }
928
929    #[test]
930    fn test_roundtrip_serialize() {
931        let yaml = r#"
932base: "alpine:3.19"
933steps:
934  - run: "echo hello"
935  - copy: "."
936    to: "/app"
937cmd: "echo done"
938"#;
939        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
940        let serialized = serde_yaml::to_string(&img).unwrap();
941        let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
942        assert_eq!(img.base, img2.base);
943        assert_eq!(img.steps.len(), img2.steps.len());
944    }
945}