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
18use crate::backend::ImageOs;
19
20// ---------------------------------------------------------------------------
21// Build context (for `build:` directive)
22// ---------------------------------------------------------------------------
23
24/// Build context for building a base image from a local Dockerfile or `ZImagefile`.
25///
26/// Supports two forms:
27///
28/// ```yaml
29/// # Short form: just a path (defaults to auto-detecting build file)
30/// build: "."
31///
32/// # Long form: explicit configuration
33/// build:
34///   context: "./subdir"     # or use `workdir:` (context is an alias)
35///   file: "ZImagefile.prod" # specific build file
36///   args:
37///     RUST_VERSION: "1.90"
38/// ```
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(untagged)]
41pub enum ZBuildContext {
42    /// Short form: just a path to the build context directory.
43    Short(String),
44    /// Long form: explicit build configuration.
45    Full {
46        /// Build context directory. Defaults to the current working directory.
47        /// `context` is accepted as an alias for Docker Compose compatibility.
48        #[serde(alias = "context", default)]
49        workdir: Option<String>,
50        /// Path to the build file (Dockerfile or `ZImagefile`).
51        /// Auto-detected if omitted (prefers `ZImagefile` over Dockerfile).
52        #[serde(default, skip_serializing_if = "Option::is_none")]
53        file: Option<String>,
54        /// Build arguments passed to the nested build.
55        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
56        args: HashMap<String, String>,
57    },
58}
59
60impl ZBuildContext {
61    /// Resolve the build context directory relative to a base path.
62    #[must_use]
63    pub fn context_dir(&self, base: &Path) -> PathBuf {
64        match self {
65            Self::Short(path) => base.join(path),
66            Self::Full { workdir, .. } => match workdir {
67                Some(dir) => base.join(dir),
68                None => base.to_path_buf(),
69            },
70        }
71    }
72
73    /// Get the explicit build file path, if specified.
74    #[must_use]
75    pub fn file(&self) -> Option<&str> {
76        match self {
77            Self::Short(_) => None,
78            Self::Full { file, .. } => file.as_deref(),
79        }
80    }
81
82    /// Get build arguments.
83    #[must_use]
84    pub fn args(&self) -> HashMap<String, String> {
85        match self {
86            Self::Short(_) => HashMap::new(),
87            Self::Full { args, .. } => args.clone(),
88        }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Top-level ZImage
94// ---------------------------------------------------------------------------
95
96/// Top-level `ZImagefile` representation.
97///
98/// Exactly one of the four mode fields must be set:
99/// - `runtime` for runtime template shorthand
100/// - `base` + `steps` for single-stage builds
101/// - `stages` for multi-stage builds
102/// - `wasm` for WebAssembly component builds
103///
104/// Common image metadata fields (env, workdir, expose, cmd, etc.) apply to
105/// the final output image regardless of mode.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct ZImage {
109    /// `ZImagefile` format version (currently must be "1")
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub version: Option<String>,
112
113    // -- Mode 1: runtime template shorthand --
114    /// Runtime template name, e.g. "node22", "python313", "rust"
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub runtime: Option<String>,
117
118    // -- Mode 2: single-stage --
119    /// Base image for single-stage builds (e.g. "alpine:3.19").
120    /// Mutually exclusive with `build`.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub base: Option<String>,
123
124    /// Build a base image from a local Dockerfile/ZImagefile context.
125    /// Mutually exclusive with `base`.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub build: Option<ZBuildContext>,
128
129    /// Build steps for single-stage mode
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub steps: Vec<ZStep>,
132
133    /// Target platform for single-stage mode (e.g. "linux/amd64")
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub platform: Option<String>,
136
137    /// Target OS for this image/stage. Overrides any OS inferred from `platform`. Default: inferred or Linux.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub os: Option<ImageOs>,
140
141    // -- Mode 3: multi-stage --
142    /// Named stages for multi-stage builds. Insertion order is preserved;
143    /// the last stage is the output image.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub stages: Option<IndexMap<String, ZStage>>,
146
147    // -- Mode 4: WASM --
148    /// WebAssembly build configuration
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub wasm: Option<ZWasmConfig>,
151
152    // -- Common image metadata --
153    /// Environment variables applied to the final image
154    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
155    pub env: HashMap<String, String>,
156
157    /// Working directory for the final image
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub workdir: Option<String>,
160
161    /// Ports to expose
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub expose: Option<ZExpose>,
164
165    /// Default command (CMD equivalent)
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub cmd: Option<ZCommand>,
168
169    /// Entrypoint command
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub entrypoint: Option<ZCommand>,
172
173    /// User to run as in the final image
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub user: Option<String>,
176
177    /// Image labels / metadata
178    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
179    pub labels: HashMap<String, String>,
180
181    /// Volume mount points
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub volumes: Vec<String>,
184
185    /// Healthcheck configuration
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub healthcheck: Option<ZHealthcheck>,
188
189    /// Signal to send when stopping the container
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub stopsignal: Option<String>,
192
193    /// Build arguments (name -> default value)
194    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
195    pub args: HashMap<String, String>,
196}
197
198impl ZImage {
199    /// Resolve the target OS for this image from the explicit `os:` field,
200    /// falling back to the OS portion of `platform:` if present, else `None`.
201    ///
202    /// Returns `None` when neither field gives a definitive answer — callers
203    /// should interpret that as "default to Linux" (i.e. the historical
204    /// behaviour for all existing `ZImagefiles`).
205    ///
206    /// A malformed `platform:` value (one that does not parse to a known
207    /// [`ImageOs`]) is ignored here on purpose: `platform:` has historically
208    /// been a free-form string and arch-only values like `"amd64"` must
209    /// continue to work. Callers wanting strict validation should use
210    /// [`ImageOs::from_str`] directly.
211    #[must_use]
212    pub fn resolve_target_os(&self) -> Option<ImageOs> {
213        if let Some(os) = self.os {
214            return Some(os);
215        }
216        if let Some(platform) = self.platform.as_deref() {
217            if let Ok(os) = platform.parse::<ImageOs>() {
218                return Some(os);
219            }
220        }
221        None
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Stage
227// ---------------------------------------------------------------------------
228
229/// A single build stage in a multi-stage `ZImagefile`.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct ZStage {
233    /// Base image for this stage (e.g. "node:22-alpine").
234    /// Mutually exclusive with `build`. One of `base` or `build` must be set.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub base: Option<String>,
237
238    /// Build a base image from a local Dockerfile/ZImagefile context.
239    /// Mutually exclusive with `base`. One of `base` or `build` must be set.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub build: Option<ZBuildContext>,
242
243    /// Target platform override (e.g. "linux/arm64")
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub platform: Option<String>,
246
247    /// Target OS for this image/stage. Overrides any OS inferred from `platform`. Default: inferred or Linux.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub os: Option<ImageOs>,
250
251    /// Build arguments scoped to this stage
252    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
253    pub args: HashMap<String, String>,
254
255    /// Environment variables for this stage
256    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
257    pub env: HashMap<String, String>,
258
259    /// Working directory for this stage
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub workdir: Option<String>,
262
263    /// Ordered build steps
264    #[serde(default, skip_serializing_if = "Vec::is_empty")]
265    pub steps: Vec<ZStep>,
266
267    /// Labels for this stage
268    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
269    pub labels: HashMap<String, String>,
270
271    /// Ports to expose
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub expose: Option<ZExpose>,
274
275    /// User to run as
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub user: Option<String>,
278
279    /// Entrypoint command
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub entrypoint: Option<ZCommand>,
282
283    /// Default command
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub cmd: Option<ZCommand>,
286
287    /// Volume mount points
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub volumes: Vec<String>,
290
291    /// Healthcheck configuration
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub healthcheck: Option<ZHealthcheck>,
294
295    /// Signal to send when stopping the container
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub stopsignal: Option<String>,
298}
299
300// ---------------------------------------------------------------------------
301// Step
302// ---------------------------------------------------------------------------
303
304/// A single build instruction within a stage.
305///
306/// Exactly one of the action fields (`run`, `copy`, `add`, `env`, `workdir`,
307/// `user`) should be set. The remaining fields are modifiers that apply to
308/// the chosen action.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310#[serde(deny_unknown_fields)]
311pub struct ZStep {
312    // -- Mutually exclusive action fields --
313    /// Shell command or exec-form command to run
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub run: Option<ZCommand>,
316
317    /// Source path(s) to copy into the image
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub copy: Option<ZCopySources>,
320
321    /// Source path(s) to add (supports URLs and auto-extraction)
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub add: Option<ZCopySources>,
324
325    /// Environment variables to set
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub env: Option<HashMap<String, String>>,
328
329    /// Change working directory
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub workdir: Option<String>,
332
333    /// Change user
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub user: Option<String>,
336
337    // -- Shared modifier fields --
338    /// Destination path (for copy/add actions)
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub to: Option<String>,
341
342    /// Source stage name for cross-stage copy (replaces `--from`)
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub from: Option<String>,
345
346    /// File ownership (replaces `--chown`)
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub owner: Option<String>,
349
350    /// File permissions (replaces `--chmod`)
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub chmod: Option<String>,
353
354    /// Cache mounts for RUN steps
355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
356    pub cache: Vec<ZCacheMount>,
357}
358
359// ---------------------------------------------------------------------------
360// Cache mount
361// ---------------------------------------------------------------------------
362
363/// A cache mount specification for RUN steps.
364///
365/// Maps to `--mount=type=cache` in Dockerfile/buildah syntax.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct ZCacheMount {
369    /// Target path inside the container where the cache is mounted
370    pub target: String,
371
372    /// Cache identifier (shared across builds with the same id)
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub id: Option<String>,
375
376    /// Sharing mode: "locked", "shared", or "private"
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub sharing: Option<String>,
379
380    /// Whether the mount is read-only
381    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
382    pub readonly: bool,
383}
384
385// ---------------------------------------------------------------------------
386// Command (shell string or exec array)
387// ---------------------------------------------------------------------------
388
389/// A command that can be specified as either a shell string or an exec-form
390/// array of strings.
391///
392/// # YAML Examples
393///
394/// Shell form:
395/// ```yaml
396/// run: "apt-get update && apt-get install -y curl"
397/// ```
398///
399/// Exec form:
400/// ```yaml
401/// cmd: ["node", "server.js"]
402/// ```
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(untagged)]
405pub enum ZCommand {
406    /// Shell form - passed to `/bin/sh -c`
407    Shell(String),
408    /// Exec form - executed directly
409    Exec(Vec<String>),
410}
411
412// ---------------------------------------------------------------------------
413// Copy sources
414// ---------------------------------------------------------------------------
415
416/// Source specification for copy/add steps. Can be a single path or multiple.
417///
418/// # YAML Examples
419///
420/// Single source:
421/// ```yaml
422/// copy: "package.json"
423/// ```
424///
425/// Multiple sources:
426/// ```yaml
427/// copy: ["package.json", "package-lock.json"]
428/// ```
429#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(untagged)]
431pub enum ZCopySources {
432    /// A single source path
433    Single(String),
434    /// Multiple source paths
435    Multiple(Vec<String>),
436}
437
438impl ZCopySources {
439    /// Convert to a vector of source paths regardless of variant.
440    #[must_use]
441    pub fn to_vec(&self) -> Vec<String> {
442        match self {
443            Self::Single(s) => vec![s.clone()],
444            Self::Multiple(v) => v.clone(),
445        }
446    }
447}
448
449// ---------------------------------------------------------------------------
450// Expose
451// ---------------------------------------------------------------------------
452
453/// Port exposure specification. Can be a single port or multiple port specs.
454///
455/// # YAML Examples
456///
457/// Single port:
458/// ```yaml
459/// expose: 8080
460/// ```
461///
462/// Multiple ports with optional protocol:
463/// ```yaml
464/// expose:
465///   - 8080
466///   - "9090/udp"
467/// ```
468#[derive(Debug, Clone, Serialize, Deserialize)]
469#[serde(untagged)]
470pub enum ZExpose {
471    /// A single port number
472    Single(u16),
473    /// Multiple port specifications
474    Multiple(Vec<ZPortSpec>),
475}
476
477// ---------------------------------------------------------------------------
478// Port spec
479// ---------------------------------------------------------------------------
480
481/// A single port specification, either a bare port number or a port with
482/// protocol suffix.
483///
484/// # YAML Examples
485///
486/// ```yaml
487/// - 8080        # bare number, defaults to TCP
488/// - "8080/tcp"  # explicit TCP
489/// - "53/udp"    # explicit UDP
490/// ```
491#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(untagged)]
493pub enum ZPortSpec {
494    /// Bare port number (defaults to TCP)
495    Number(u16),
496    /// Port with protocol, e.g. "8080/tcp" or "53/udp"
497    WithProtocol(String),
498}
499
500// ---------------------------------------------------------------------------
501// Healthcheck
502// ---------------------------------------------------------------------------
503
504/// Healthcheck configuration for the container.
505///
506/// # YAML Example
507///
508/// ```yaml
509/// healthcheck:
510///   cmd: "curl -f http://localhost:8080/health || exit 1"
511///   interval: "30s"
512///   timeout: "10s"
513///   start_period: "5s"
514///   retries: 3
515/// ```
516#[derive(Debug, Clone, Serialize, Deserialize)]
517#[serde(deny_unknown_fields)]
518pub struct ZHealthcheck {
519    /// Command to run for the health check
520    pub cmd: ZCommand,
521
522    /// Interval between health checks (e.g. "30s", "1m")
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub interval: Option<String>,
525
526    /// Timeout for each health check (e.g. "10s")
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub timeout: Option<String>,
529
530    /// Grace period before first check (e.g. "5s")
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub start_period: Option<String>,
533
534    /// Number of consecutive failures before unhealthy
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub retries: Option<u32>,
537}
538
539// ---------------------------------------------------------------------------
540// WASM config
541// ---------------------------------------------------------------------------
542
543/// WebAssembly build configuration for WASM mode.
544///
545/// # YAML Example
546///
547/// ```yaml
548/// wasm:
549///   target: "preview2"
550///   optimize: true
551///   opt_level: "Oz"
552///   language: "rust"
553///   world: "zlayer-http-handler"
554///   wit: "./wit"
555///   output: "./output.wasm"
556///   features: [json, metrics]
557///   build_args:
558///     CARGO_PROFILE_RELEASE_LTO: "true"
559///   pre_build:
560///     - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
561///   post_build:
562///     - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
563///   adapter: "./wasi_snapshot_preview1.reactor.wasm"
564/// ```
565#[derive(Debug, Clone, Serialize, Deserialize)]
566#[serde(deny_unknown_fields)]
567pub struct ZWasmConfig {
568    /// WASI target version: "preview1" or "preview2" (default: "preview2")
569    #[serde(default = "default_wasm_target")]
570    pub target: String,
571
572    /// Whether to run wasm-opt on the output
573    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
574    pub optimize: bool,
575
576    /// Optimization level for wasm-opt: "O", "Os", "Oz", "O2", "O3" (default: "Oz")
577    #[serde(
578        default = "default_wasm_opt_level",
579        skip_serializing_if = "Option::is_none"
580    )]
581    pub opt_level: Option<String>,
582
583    /// Source language (auto-detected if omitted): rust, go, python, typescript, assemblyscript, c, zig
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub language: Option<String>,
586
587    /// Path to WIT definitions (default: "./wit")
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub wit: Option<String>,
590
591    /// Target WIT world name (e.g., "zlayer-http-handler", "zlayer-plugin", "zlayer-transformer",
592    /// "zlayer-authenticator", "zlayer-rate-limiter", "zlayer-middleware", "zlayer-router")
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub world: Option<String>,
595
596    /// Output path for the compiled WASM file
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub output: Option<String>,
599
600    /// Language-specific features to enable during build
601    #[serde(default, skip_serializing_if = "Vec::is_empty")]
602    pub features: Vec<String>,
603
604    /// Additional build arguments (language-specific, e.g. `CARGO_PROFILE_RELEASE_LTO`)
605    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
606    pub build_args: HashMap<String, String>,
607
608    /// Pre-build commands to run before compilation (e.g., WIT binding generation for Go)
609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
610    pub pre_build: Vec<ZCommand>,
611
612    /// Post-build commands to run after compilation (before optimization)
613    #[serde(default, skip_serializing_if = "Vec::is_empty")]
614    pub post_build: Vec<ZCommand>,
615
616    /// Component adapter path for WASI preview1 -> preview2 lifting
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub adapter: Option<String>,
619
620    /// When false, skip OCI artifact packaging and push — only produce the raw .wasm.
621    /// Compilation and caching are unaffected. Default: true.
622    #[serde(
623        default = "default_wasm_oci",
624        skip_serializing_if = "crate::zimage::types::is_true"
625    )]
626    pub oci: bool,
627}
628
629// ---------------------------------------------------------------------------
630// Helpers
631// ---------------------------------------------------------------------------
632
633/// Default WASM target version.
634fn default_wasm_target() -> String {
635    "preview2".to_string()
636}
637
638/// Default WASM optimization level.
639#[allow(clippy::unnecessary_wraps)]
640fn default_wasm_opt_level() -> Option<String> {
641    Some("Oz".to_string())
642}
643
644/// Helper for `skip_serializing_if` on boolean fields.
645#[allow(clippy::trivially_copy_pass_by_ref)]
646fn is_false(v: &bool) -> bool {
647    !v
648}
649
650/// Helper for `skip_serializing_if` on boolean fields whose default is `true`.
651#[allow(clippy::trivially_copy_pass_by_ref)]
652pub(crate) fn is_true(v: &bool) -> bool {
653    *v
654}
655
656/// Default for `wasm.oci`: produce the OCI layout alongside the raw `.wasm`.
657fn default_wasm_oci() -> bool {
658    true
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    #[test]
666    fn test_runtime_mode_deserialize() {
667        let yaml = r#"
668runtime: node22
669cmd: "node server.js"
670"#;
671        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
672        assert_eq!(img.runtime.as_deref(), Some("node22"));
673        assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
674    }
675
676    #[test]
677    fn test_single_stage_deserialize() {
678        let yaml = r#"
679base: "alpine:3.19"
680steps:
681  - run: "apk add --no-cache curl"
682  - copy: "app.sh"
683    to: "/usr/local/bin/app.sh"
684    chmod: "755"
685  - workdir: "/app"
686env:
687  NODE_ENV: production
688expose: 8080
689cmd: ["./app.sh"]
690"#;
691        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
692        assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
693        assert_eq!(img.steps.len(), 3);
694        assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
695        assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
696        assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
697    }
698
699    #[test]
700    fn test_multi_stage_deserialize() {
701        let yaml = r#"
702stages:
703  builder:
704    base: "node:22-alpine"
705    workdir: "/src"
706    steps:
707      - copy: ["package.json", "package-lock.json"]
708        to: "./"
709      - run: "npm ci"
710      - copy: "."
711        to: "."
712      - run: "npm run build"
713  runtime:
714    base: "node:22-alpine"
715    workdir: "/app"
716    steps:
717      - copy: "dist"
718        from: builder
719        to: "/app"
720    cmd: ["node", "dist/index.js"]
721expose: 3000
722"#;
723        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
724        let stages = img.stages.as_ref().unwrap();
725        assert_eq!(stages.len(), 2);
726
727        // Verify insertion order is preserved
728        let keys: Vec<&String> = stages.keys().collect();
729        assert_eq!(keys, vec!["builder", "runtime"]);
730
731        let builder = &stages["builder"];
732        assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
733        assert_eq!(builder.steps.len(), 4);
734
735        let runtime = &stages["runtime"];
736        assert_eq!(runtime.steps.len(), 1);
737        assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
738    }
739
740    #[test]
741    fn test_wasm_mode_deserialize() {
742        let yaml = r#"
743wasm:
744  target: preview2
745  optimize: true
746  language: rust
747  wit: "./wit"
748  output: "./output.wasm"
749"#;
750        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
751        let wasm = img.wasm.as_ref().unwrap();
752        assert_eq!(wasm.target, "preview2");
753        assert!(wasm.optimize);
754        assert_eq!(wasm.language.as_deref(), Some("rust"));
755        assert_eq!(wasm.wit.as_deref(), Some("./wit"));
756        assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
757    }
758
759    #[test]
760    fn test_wasm_defaults() {
761        let yaml = r"
762wasm: {}
763";
764        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
765        let wasm = img.wasm.as_ref().unwrap();
766        assert_eq!(wasm.target, "preview2");
767        assert!(!wasm.optimize);
768        assert!(wasm.language.is_none());
769        assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
770        assert!(wasm.world.is_none());
771        assert!(wasm.features.is_empty());
772        assert!(wasm.build_args.is_empty());
773        assert!(wasm.pre_build.is_empty());
774        assert!(wasm.post_build.is_empty());
775        assert!(wasm.adapter.is_none());
776        assert!(
777            wasm.oci,
778            "default ZWasmConfig.oci must be true so OCI packaging still happens unless explicitly opted out"
779        );
780    }
781
782    #[test]
783    fn test_wasm_oci_opt_out() {
784        let yaml = r"
785wasm:
786  oci: false
787";
788        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
789        let wasm = img.wasm.as_ref().unwrap();
790        assert!(
791            !wasm.oci,
792            "wasm.oci: false must deserialize to ZWasmConfig.oci == false"
793        );
794    }
795
796    #[test]
797    fn test_wasm_full_config() {
798        let yaml = r#"
799wasm:
800  target: "preview2"
801  optimize: true
802  opt_level: "O3"
803  language: "rust"
804  world: "zlayer-http-handler"
805  wit: "./wit"
806  output: "./output.wasm"
807  features:
808    - json
809    - metrics
810  build_args:
811    CARGO_PROFILE_RELEASE_LTO: "true"
812    RUSTFLAGS: "-C target-feature=+simd128"
813  pre_build:
814    - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
815  post_build:
816    - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
817  adapter: "./wasi_snapshot_preview1.reactor.wasm"
818"#;
819        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
820        let wasm = img.wasm.as_ref().unwrap();
821        assert_eq!(wasm.target, "preview2");
822        assert!(wasm.optimize);
823        assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
824        assert_eq!(wasm.language.as_deref(), Some("rust"));
825        assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
826        assert_eq!(wasm.wit.as_deref(), Some("./wit"));
827        assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
828        assert_eq!(wasm.features, vec!["json", "metrics"]);
829        assert_eq!(
830            wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
831            "true"
832        );
833        assert_eq!(
834            wasm.build_args.get("RUSTFLAGS").unwrap(),
835            "-C target-feature=+simd128"
836        );
837        assert_eq!(wasm.pre_build.len(), 1);
838        assert_eq!(wasm.post_build.len(), 1);
839        assert_eq!(
840            wasm.adapter.as_deref(),
841            Some("./wasi_snapshot_preview1.reactor.wasm")
842        );
843    }
844
845    #[test]
846    fn test_zcommand_shell() {
847        let yaml = r#""echo hello""#;
848        let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
849        assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
850    }
851
852    #[test]
853    fn test_zcommand_exec() {
854        let yaml = r#"["echo", "hello"]"#;
855        let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
856        assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
857    }
858
859    #[test]
860    fn test_zcopy_sources_single() {
861        let yaml = r#""package.json""#;
862        let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
863        assert_eq!(src.to_vec(), vec!["package.json"]);
864    }
865
866    #[test]
867    fn test_zcopy_sources_multiple() {
868        let yaml = r#"["package.json", "tsconfig.json"]"#;
869        let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
870        assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
871    }
872
873    #[test]
874    fn test_zexpose_single() {
875        let yaml = "8080";
876        let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
877        assert!(matches!(exp, ZExpose::Single(8080)));
878    }
879
880    #[test]
881    fn test_zexpose_multiple() {
882        let yaml = r#"
883- 8080
884- "9090/udp"
885"#;
886        let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
887        if let ZExpose::Multiple(ports) = exp {
888            assert_eq!(ports.len(), 2);
889            assert!(matches!(ports[0], ZPortSpec::Number(8080)));
890            assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
891        } else {
892            panic!("Expected ZExpose::Multiple");
893        }
894    }
895
896    #[test]
897    fn test_healthcheck_deserialize() {
898        let yaml = r#"
899cmd: "curl -f http://localhost/ || exit 1"
900interval: "30s"
901timeout: "10s"
902start_period: "5s"
903retries: 3
904"#;
905        let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
906        assert!(matches!(hc.cmd, ZCommand::Shell(_)));
907        assert_eq!(hc.interval.as_deref(), Some("30s"));
908        assert_eq!(hc.timeout.as_deref(), Some("10s"));
909        assert_eq!(hc.start_period.as_deref(), Some("5s"));
910        assert_eq!(hc.retries, Some(3));
911    }
912
913    #[test]
914    fn test_cache_mount_deserialize() {
915        let yaml = r"
916target: /var/cache/apt
917id: apt-cache
918sharing: shared
919readonly: false
920";
921        let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
922        assert_eq!(cm.target, "/var/cache/apt");
923        assert_eq!(cm.id.as_deref(), Some("apt-cache"));
924        assert_eq!(cm.sharing.as_deref(), Some("shared"));
925        assert!(!cm.readonly);
926    }
927
928    #[test]
929    fn test_step_with_cache_mounts() {
930        let yaml = r#"
931run: "apt-get update && apt-get install -y curl"
932cache:
933  - target: /var/cache/apt
934    id: apt-cache
935    sharing: shared
936  - target: /var/lib/apt
937    readonly: true
938"#;
939        let step: ZStep = serde_yaml::from_str(yaml).unwrap();
940        assert!(step.run.is_some());
941        assert_eq!(step.cache.len(), 2);
942        assert_eq!(step.cache[0].target, "/var/cache/apt");
943        assert!(step.cache[1].readonly);
944    }
945
946    #[test]
947    fn test_deny_unknown_fields_zimage() {
948        let yaml = r#"
949base: "alpine:3.19"
950bogus_field: "should fail"
951"#;
952        let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
953        assert!(result.is_err(), "Should reject unknown fields");
954    }
955
956    #[test]
957    fn test_deny_unknown_fields_zstep() {
958        let yaml = r#"
959run: "echo hello"
960bogus: "nope"
961"#;
962        let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
963        assert!(result.is_err(), "Should reject unknown fields on ZStep");
964    }
965
966    // -----------------------------------------------------------------------
967    // L-2: os: field + resolve_target_os priority
968    // -----------------------------------------------------------------------
969
970    #[test]
971    fn test_zimage_os_field_linux() {
972        let yaml = r#"
973base: "alpine:3.19"
974os: linux
975"#;
976        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
977        assert_eq!(img.os, Some(ImageOs::Linux));
978        assert_eq!(img.resolve_target_os(), Some(ImageOs::Linux));
979    }
980
981    #[test]
982    fn test_zimage_os_field_windows() {
983        let yaml = r#"
984base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
985os: windows
986"#;
987        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
988        assert_eq!(img.os, Some(ImageOs::Windows));
989        assert_eq!(img.resolve_target_os(), Some(ImageOs::Windows));
990    }
991
992    #[test]
993    fn test_zimage_os_missing_is_none() {
994        // With no `os:` and no `platform:`, callers should interpret None as
995        // "default to Linux". The serde-default here must stay `None` so we
996        // never silently override an explicit platform further down.
997        let yaml = r#"
998base: "alpine:3.19"
999"#;
1000        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1001        assert_eq!(img.os, None);
1002        assert_eq!(img.resolve_target_os(), None);
1003    }
1004
1005    #[test]
1006    fn test_zimage_os_wins_over_platform() {
1007        // `os:` and `platform:` can both be set. `os:` determines the target
1008        // OS; `platform:` keeps its role as the full platform triple (arch
1009        // info is still carried by `platform:`).
1010        let yaml = r#"
1011base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1012platform: "linux/amd64"
1013os: windows
1014"#;
1015        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1016        assert_eq!(img.platform.as_deref(), Some("linux/amd64"));
1017        assert_eq!(img.os, Some(ImageOs::Windows));
1018        assert_eq!(
1019            img.resolve_target_os(),
1020            Some(ImageOs::Windows),
1021            "explicit os: must win over OS inferred from platform:"
1022        );
1023    }
1024
1025    #[test]
1026    fn test_zimage_os_inferred_from_platform() {
1027        // No explicit `os:` — fall back to the OS portion of `platform:`.
1028        let yaml = r#"
1029base: "alpine:3.19"
1030platform: "linux/arm64"
1031"#;
1032        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1033        assert_eq!(img.os, None);
1034        assert_eq!(
1035            img.resolve_target_os(),
1036            Some(ImageOs::Linux),
1037            "resolve_target_os must fall back to parsing platform:"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_zimage_os_unknown_platform_ignored() {
1043        // Arch-only or unknown `platform:` values stay free-form. The OS
1044        // resolver returns None and the caller defaults to Linux.
1045        let yaml = r#"
1046base: "alpine:3.19"
1047platform: "amd64"
1048"#;
1049        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1050        assert_eq!(img.resolve_target_os(), None);
1051    }
1052
1053    #[test]
1054    fn test_zstage_os_field() {
1055        let yaml = r#"
1056stages:
1057  builder:
1058    base: "alpine:3.19"
1059    os: linux
1060  runtime:
1061    base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
1062    os: windows
1063"#;
1064        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1065        let stages = img.stages.as_ref().unwrap();
1066        assert_eq!(stages["builder"].os, Some(ImageOs::Linux));
1067        assert_eq!(stages["runtime"].os, Some(ImageOs::Windows));
1068    }
1069
1070    #[test]
1071    fn test_roundtrip_serialize() {
1072        let yaml = r#"
1073base: "alpine:3.19"
1074steps:
1075  - run: "echo hello"
1076  - copy: "."
1077    to: "/app"
1078cmd: "echo done"
1079"#;
1080        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
1081        let serialized = serde_yaml::to_string(&img).unwrap();
1082        let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
1083        assert_eq!(img.base, img2.base);
1084        assert_eq!(img.steps.len(), img2.steps.len());
1085    }
1086}