Skip to main content

zlayer_builder/zimage/
converter.rs

1//! ZImage-to-Dockerfile converter
2//!
3//! Converts a parsed [`ZImage`] into the internal [`Dockerfile`] IR so the
4//! existing buildah pipeline can execute `ZImagefile` builds without any
5//! changes to the downstream execution logic.
6
7use std::collections::HashMap;
8
9use crate::dockerfile::{
10    AddInstruction, ArgInstruction, CopyInstruction, EnvInstruction, ExposeInstruction,
11    HealthcheckInstruction, ImageRef, Instruction, RunInstruction, RunMount, ShellOrExec, Stage,
12};
13use crate::error::{BuildError, Result};
14
15use super::types::{
16    ZCacheMount, ZCommand, ZExpose, ZHealthcheck, ZImage, ZPortSpec, ZStage, ZStep,
17};
18
19/// The Dockerfile IR type from the parser module.
20use crate::dockerfile::Dockerfile;
21
22/// Cache sharing type re-used from the instruction module.
23use crate::dockerfile::CacheSharing;
24
25// ---------------------------------------------------------------------------
26// Public entry point
27// ---------------------------------------------------------------------------
28
29/// Convert a parsed [`ZImage`] into the internal [`Dockerfile`] IR.
30///
31/// Supports two modes:
32///
33/// 1. **Single-stage** -- when `base` is set at the top level.
34/// 2. **Multi-stage** -- when `stages` is set (an `IndexMap` of named stages).
35///
36/// Runtime-only and WASM modes are not convertible to Dockerfile IR; they
37/// are handled separately by the builder.
38///
39/// # Errors
40///
41/// Returns [`BuildError::ZImagefileValidation`] if the `ZImage` cannot be
42/// meaningfully converted (e.g. no `base` or `stages` present, or a
43/// healthcheck duration string cannot be parsed).
44pub fn zimage_to_dockerfile(zimage: &ZImage) -> Result<Dockerfile> {
45    // Convert global args.
46    let global_args = convert_global_args(&zimage.args);
47
48    // Choose single-stage or multi-stage.
49    // Note: `build:` directives must be resolved to `base:` by the caller
50    // (ImageBuilder) before calling this function.
51    let stages = if let Some(ref base) = zimage.base {
52        vec![convert_single_stage(zimage, base)?]
53    } else if let Some(ref stage_map) = zimage.stages {
54        convert_multi_stage(zimage, stage_map)?
55    } else if zimage.build.is_some() {
56        return Err(BuildError::zimagefile_validation(
57            "ZImage has 'build' set but it was not resolved to a 'base' image. \
58             This is an internal error — build directives must be resolved before conversion.",
59        ));
60    } else {
61        return Err(BuildError::zimagefile_validation(
62            "ZImage must have 'base', 'build', or 'stages' set to convert to a Dockerfile",
63        ));
64    };
65
66    Ok(Dockerfile {
67        global_args,
68        stages,
69    })
70}
71
72// ---------------------------------------------------------------------------
73// Global args
74// ---------------------------------------------------------------------------
75
76fn convert_global_args(args: &HashMap<String, String>) -> Vec<ArgInstruction> {
77    let mut result: Vec<ArgInstruction> = args
78        .iter()
79        .map(|(name, default)| {
80            if default.is_empty() {
81                ArgInstruction::new(name)
82            } else {
83                ArgInstruction::with_default(name, default)
84            }
85        })
86        .collect();
87    // Sort for deterministic output.
88    result.sort_by(|a, b| a.name.cmp(&b.name));
89    result
90}
91
92// ---------------------------------------------------------------------------
93// Single-stage conversion
94// ---------------------------------------------------------------------------
95
96fn convert_single_stage(zimage: &ZImage, base: &str) -> Result<Stage> {
97    let base_image = ImageRef::parse(base);
98    let mut instructions = Vec::new();
99
100    // env
101    if !zimage.env.is_empty() {
102        instructions.push(Instruction::Env(EnvInstruction::from_vars(
103            zimage.env.clone(),
104        )));
105    }
106
107    // workdir
108    if let Some(ref wd) = zimage.workdir {
109        instructions.push(Instruction::Workdir(wd.clone()));
110    }
111
112    // steps
113    for step in &zimage.steps {
114        instructions.push(convert_step(step)?);
115    }
116
117    // labels
118    if !zimage.labels.is_empty() {
119        instructions.push(Instruction::Label(zimage.labels.clone()));
120    }
121
122    // expose
123    if let Some(ref expose) = zimage.expose {
124        instructions.extend(convert_expose(expose)?);
125    }
126
127    // user
128    if let Some(ref user) = zimage.user {
129        instructions.push(Instruction::User(user.clone()));
130    }
131
132    // volumes
133    if !zimage.volumes.is_empty() {
134        instructions.push(Instruction::Volume(zimage.volumes.clone()));
135    }
136
137    // healthcheck
138    if let Some(ref hc) = zimage.healthcheck {
139        instructions.push(convert_healthcheck(hc)?);
140    }
141
142    // stopsignal
143    if let Some(ref sig) = zimage.stopsignal {
144        instructions.push(Instruction::Stopsignal(sig.clone()));
145    }
146
147    // entrypoint
148    if let Some(ref ep) = zimage.entrypoint {
149        instructions.push(Instruction::Entrypoint(convert_command(ep)));
150    }
151
152    // cmd
153    if let Some(ref cmd) = zimage.cmd {
154        instructions.push(Instruction::Cmd(convert_command(cmd)));
155    }
156
157    Ok(Stage {
158        index: 0,
159        name: None,
160        base_image,
161        platform: zimage.platform.clone(),
162        instructions,
163    })
164}
165
166// ---------------------------------------------------------------------------
167// Multi-stage conversion
168// ---------------------------------------------------------------------------
169
170#[allow(clippy::too_many_lines)]
171fn convert_multi_stage(
172    zimage: &ZImage,
173    stage_map: &indexmap::IndexMap<String, ZStage>,
174) -> Result<Vec<Stage>> {
175    let stage_names: Vec<&String> = stage_map.keys().collect();
176    let mut stages = Vec::with_capacity(stage_map.len());
177
178    for (idx, (name, zstage)) in stage_map.iter().enumerate() {
179        // `build:` directives must be resolved to `base:` before conversion.
180        let base_str = zstage.base.as_deref().ok_or_else(|| {
181            if zstage.build.is_some() {
182                BuildError::zimagefile_validation(format!(
183                    "stage '{name}': 'build' directive was not resolved to a 'base' image. \
184                     This is an internal error."
185                ))
186            } else {
187                BuildError::zimagefile_validation(format!(
188                    "stage '{name}': must have 'base' or 'build' set"
189                ))
190            }
191        })?;
192
193        let base_image = if stage_names.iter().any(|s| s.as_str() == base_str) {
194            ImageRef::Stage(base_str.to_string())
195        } else {
196            ImageRef::parse(base_str)
197        };
198
199        let mut instructions = Vec::new();
200
201        // Stage-level args
202        for (arg_name, arg_default) in &zstage.args {
203            if arg_default.is_empty() {
204                instructions.push(Instruction::Arg(ArgInstruction::new(arg_name)));
205            } else {
206                instructions.push(Instruction::Arg(ArgInstruction::with_default(
207                    arg_name,
208                    arg_default,
209                )));
210            }
211        }
212
213        // env
214        if !zstage.env.is_empty() {
215            instructions.push(Instruction::Env(EnvInstruction::from_vars(
216                zstage.env.clone(),
217            )));
218        }
219
220        // workdir
221        if let Some(ref wd) = zstage.workdir {
222            instructions.push(Instruction::Workdir(wd.clone()));
223        }
224
225        // steps
226        for step in &zstage.steps {
227            instructions.push(convert_step(step)?);
228        }
229
230        // labels
231        if !zstage.labels.is_empty() {
232            instructions.push(Instruction::Label(zstage.labels.clone()));
233        }
234
235        // expose
236        if let Some(ref expose) = zstage.expose {
237            instructions.extend(convert_expose(expose)?);
238        }
239
240        // user
241        if let Some(ref user) = zstage.user {
242            instructions.push(Instruction::User(user.clone()));
243        }
244
245        // volumes
246        if !zstage.volumes.is_empty() {
247            instructions.push(Instruction::Volume(zstage.volumes.clone()));
248        }
249
250        // healthcheck
251        if let Some(ref hc) = zstage.healthcheck {
252            instructions.push(convert_healthcheck(hc)?);
253        }
254
255        // stopsignal
256        if let Some(ref sig) = zstage.stopsignal {
257            instructions.push(Instruction::Stopsignal(sig.clone()));
258        }
259
260        // entrypoint
261        if let Some(ref ep) = zstage.entrypoint {
262            instructions.push(Instruction::Entrypoint(convert_command(ep)));
263        }
264
265        // cmd
266        if let Some(ref cmd) = zstage.cmd {
267            instructions.push(Instruction::Cmd(convert_command(cmd)));
268        }
269
270        stages.push(Stage {
271            index: idx,
272            name: Some(name.clone()),
273            base_image,
274            platform: zstage.platform.clone(),
275            instructions,
276        });
277    }
278
279    // Append top-level metadata to the *last* stage (the output image).
280    if let Some(last) = stages.last_mut() {
281        // Top-level env merges into the final stage.
282        if !zimage.env.is_empty() {
283            last.instructions
284                .push(Instruction::Env(EnvInstruction::from_vars(
285                    zimage.env.clone(),
286                )));
287        }
288
289        if let Some(ref wd) = zimage.workdir {
290            last.instructions.push(Instruction::Workdir(wd.clone()));
291        }
292
293        if !zimage.labels.is_empty() {
294            last.instructions
295                .push(Instruction::Label(zimage.labels.clone()));
296        }
297
298        if let Some(ref expose) = zimage.expose {
299            last.instructions.extend(convert_expose(expose)?);
300        }
301
302        if let Some(ref user) = zimage.user {
303            last.instructions.push(Instruction::User(user.clone()));
304        }
305
306        if !zimage.volumes.is_empty() {
307            last.instructions
308                .push(Instruction::Volume(zimage.volumes.clone()));
309        }
310
311        if let Some(ref hc) = zimage.healthcheck {
312            last.instructions.push(convert_healthcheck(hc)?);
313        }
314
315        if let Some(ref sig) = zimage.stopsignal {
316            last.instructions.push(Instruction::Stopsignal(sig.clone()));
317        }
318
319        if let Some(ref ep) = zimage.entrypoint {
320            last.instructions
321                .push(Instruction::Entrypoint(convert_command(ep)));
322        }
323
324        if let Some(ref cmd) = zimage.cmd {
325            last.instructions
326                .push(Instruction::Cmd(convert_command(cmd)));
327        }
328    }
329
330    Ok(stages)
331}
332
333// ---------------------------------------------------------------------------
334// Step conversion
335// ---------------------------------------------------------------------------
336
337fn convert_step(step: &ZStep) -> Result<Instruction> {
338    if let Some(ref cmd) = step.run {
339        return Ok(convert_run(cmd, &step.cache, step));
340    }
341
342    if let Some(ref sources) = step.copy {
343        return Ok(convert_copy(sources, step));
344    }
345
346    if let Some(ref sources) = step.add {
347        return Ok(convert_add(sources, step));
348    }
349
350    if let Some(ref vars) = step.env {
351        return Ok(Instruction::Env(EnvInstruction::from_vars(vars.clone())));
352    }
353
354    if let Some(ref wd) = step.workdir {
355        return Ok(Instruction::Workdir(wd.clone()));
356    }
357
358    if let Some(ref user) = step.user {
359        return Ok(Instruction::User(user.clone()));
360    }
361
362    // Should not be reachable if the parser validated the step, but guard
363    // against it anyway.
364    Err(BuildError::zimagefile_validation(
365        "step has no recognised instruction (run, copy, add, env, workdir, user)",
366    ))
367}
368
369// ---------------------------------------------------------------------------
370// RUN
371// ---------------------------------------------------------------------------
372
373fn convert_run(cmd: &ZCommand, caches: &[ZCacheMount], _step: &ZStep) -> Instruction {
374    let command = convert_command(cmd);
375    let mounts: Vec<RunMount> = caches.iter().map(convert_cache_mount).collect();
376
377    Instruction::Run(RunInstruction {
378        command,
379        mounts,
380        network: None,
381        security: None,
382    })
383}
384
385// ---------------------------------------------------------------------------
386// COPY / ADD
387// ---------------------------------------------------------------------------
388
389fn convert_copy(sources: &super::types::ZCopySources, step: &ZStep) -> Instruction {
390    let destination = step.to.clone().unwrap_or_default();
391    Instruction::Copy(CopyInstruction {
392        sources: sources.to_vec(),
393        destination,
394        from: step.from.clone(),
395        chown: step.owner.clone(),
396        chmod: step.chmod.clone(),
397        link: false,
398        exclude: Vec::new(),
399    })
400}
401
402fn convert_add(sources: &super::types::ZCopySources, step: &ZStep) -> Instruction {
403    let destination = step.to.clone().unwrap_or_default();
404    Instruction::Add(AddInstruction {
405        sources: sources.to_vec(),
406        destination,
407        chown: step.owner.clone(),
408        chmod: step.chmod.clone(),
409        link: false,
410        checksum: None,
411        keep_git_dir: false,
412    })
413}
414
415// ---------------------------------------------------------------------------
416// Command helper
417// ---------------------------------------------------------------------------
418
419fn convert_command(cmd: &ZCommand) -> ShellOrExec {
420    match cmd {
421        ZCommand::Shell(s) => ShellOrExec::Shell(s.clone()),
422        ZCommand::Exec(v) => ShellOrExec::Exec(v.clone()),
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Expose
428// ---------------------------------------------------------------------------
429
430fn convert_expose(expose: &ZExpose) -> Result<Vec<Instruction>> {
431    match expose {
432        ZExpose::Single(port) => Ok(vec![Instruction::Expose(ExposeInstruction::tcp(*port))]),
433        ZExpose::Multiple(specs) => {
434            let mut out = Vec::with_capacity(specs.len());
435            for spec in specs {
436                out.push(convert_port_spec(spec)?);
437            }
438            Ok(out)
439        }
440    }
441}
442
443fn convert_port_spec(spec: &ZPortSpec) -> Result<Instruction> {
444    match spec {
445        ZPortSpec::Number(port) => Ok(Instruction::Expose(ExposeInstruction::tcp(*port))),
446        ZPortSpec::WithProtocol(s) => {
447            let (port_str, proto_str) = s.split_once('/').ok_or_else(|| {
448                BuildError::zimagefile_validation(format!(
449                    "invalid port spec '{s}', expected format '<port>/<protocol>'"
450                ))
451            })?;
452
453            let port: u16 = port_str.parse().map_err(|_| {
454                BuildError::zimagefile_validation(format!("invalid port number: '{port_str}'"))
455            })?;
456
457            let inst = match proto_str.to_lowercase().as_str() {
458                "udp" => ExposeInstruction::udp(port),
459                _ => ExposeInstruction::tcp(port),
460            };
461
462            Ok(Instruction::Expose(inst))
463        }
464    }
465}
466
467// ---------------------------------------------------------------------------
468// Healthcheck
469// ---------------------------------------------------------------------------
470
471fn convert_healthcheck(hc: &ZHealthcheck) -> Result<Instruction> {
472    let command = convert_command(&hc.cmd);
473
474    let interval = parse_optional_duration(hc.interval.as_ref(), "healthcheck interval")?;
475    let timeout = parse_optional_duration(hc.timeout.as_ref(), "healthcheck timeout")?;
476    let start_period =
477        parse_optional_duration(hc.start_period.as_ref(), "healthcheck start_period")?;
478
479    Ok(Instruction::Healthcheck(HealthcheckInstruction::Check {
480        command,
481        interval,
482        timeout,
483        start_period,
484        start_interval: None,
485        retries: hc.retries,
486    }))
487}
488
489fn parse_optional_duration(
490    value: Option<&String>,
491    label: &str,
492) -> Result<Option<std::time::Duration>> {
493    match value {
494        None => Ok(None),
495        Some(s) => {
496            let dur = humantime::parse_duration(s).map_err(|e| {
497                BuildError::zimagefile_validation(format!("invalid {label} '{s}': {e}"))
498            })?;
499            Ok(Some(dur))
500        }
501    }
502}
503
504// ---------------------------------------------------------------------------
505// Cache mount
506// ---------------------------------------------------------------------------
507
508#[must_use]
509pub fn convert_cache_mount(cm: &ZCacheMount) -> RunMount {
510    let sharing = match cm.sharing.as_deref() {
511        Some("shared") => CacheSharing::Shared,
512        Some("private") => CacheSharing::Private,
513        // "locked", unknown values, or None all fall back to the default.
514        Some(_) | None => CacheSharing::Locked,
515    };
516
517    RunMount::Cache {
518        target: cm.target.clone(),
519        id: cm.id.clone(),
520        sharing,
521        readonly: cm.readonly,
522    }
523}
524
525// ---------------------------------------------------------------------------
526// Tests
527// ---------------------------------------------------------------------------
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::zimage::parse_zimagefile;
533
534    // -- Helpers ----------------------------------------------------------
535
536    fn parse_and_convert(yaml: &str) -> Dockerfile {
537        let zimage = parse_zimagefile(yaml).expect("YAML parse failed");
538        zimage_to_dockerfile(&zimage).expect("conversion failed")
539    }
540
541    // -- Single-stage tests -----------------------------------------------
542
543    #[test]
544    fn test_single_stage_basic() {
545        let df = parse_and_convert(
546            r#"
547base: "alpine:3.19"
548steps:
549  - run: "apk add --no-cache curl"
550  - copy: "app.sh"
551    to: "/usr/local/bin/app.sh"
552    chmod: "755"
553  - workdir: "/app"
554cmd: ["./app.sh"]
555"#,
556        );
557
558        assert_eq!(df.stages.len(), 1);
559        let stage = &df.stages[0];
560        assert_eq!(stage.index, 0);
561        assert!(stage.name.is_none());
562
563        // base image
564        assert!(matches!(
565            &stage.base_image,
566            ImageRef::Registry { image, tag: Some(t), .. } if image == "alpine" && t == "3.19"
567        ));
568
569        // Should contain RUN, COPY, WORKDIR, CMD
570        let names: Vec<&str> = stage
571            .instructions
572            .iter()
573            .map(crate::Instruction::name)
574            .collect();
575        assert!(names.contains(&"RUN"));
576        assert!(names.contains(&"COPY"));
577        assert!(names.contains(&"WORKDIR"));
578        assert!(names.contains(&"CMD"));
579    }
580
581    #[test]
582    fn test_single_stage_env_and_expose() {
583        let df = parse_and_convert(
584            r#"
585base: "node:22-alpine"
586env:
587  NODE_ENV: production
588expose: 3000
589cmd: "node server.js"
590"#,
591        );
592
593        let stage = &df.stages[0];
594        let has_env = stage.instructions.iter().any(|i| matches!(i, Instruction::Env(e) if e.vars.get("NODE_ENV") == Some(&"production".to_string())));
595        assert!(has_env);
596
597        let has_expose = stage
598            .instructions
599            .iter()
600            .any(|i| matches!(i, Instruction::Expose(e) if e.port == 3000));
601        assert!(has_expose);
602    }
603
604    #[test]
605    fn test_single_stage_healthcheck() {
606        let df = parse_and_convert(
607            r#"
608base: "alpine:3.19"
609healthcheck:
610  cmd: "curl -f http://localhost/ || exit 1"
611  interval: "30s"
612  timeout: "10s"
613  start_period: "5s"
614  retries: 3
615"#,
616        );
617
618        let stage = &df.stages[0];
619        let hc = stage
620            .instructions
621            .iter()
622            .find(|i| matches!(i, Instruction::Healthcheck(_)));
623        assert!(hc.is_some());
624
625        if let Some(Instruction::Healthcheck(HealthcheckInstruction::Check {
626            interval,
627            timeout,
628            start_period,
629            retries,
630            ..
631        })) = hc
632        {
633            assert_eq!(*interval, Some(std::time::Duration::from_secs(30)));
634            assert_eq!(*timeout, Some(std::time::Duration::from_secs(10)));
635            assert_eq!(*start_period, Some(std::time::Duration::from_secs(5)));
636            assert_eq!(*retries, Some(3));
637        } else {
638            panic!("Expected Healthcheck::Check");
639        }
640    }
641
642    #[test]
643    fn test_global_args() {
644        let df = parse_and_convert(
645            r#"
646base: "alpine:3.19"
647args:
648  VERSION: "1.0"
649  BUILD_TYPE: ""
650"#,
651        );
652
653        assert_eq!(df.global_args.len(), 2);
654
655        let version = df.global_args.iter().find(|a| a.name == "VERSION");
656        assert!(version.is_some());
657        assert_eq!(version.unwrap().default, Some("1.0".to_string()));
658
659        let build_type = df.global_args.iter().find(|a| a.name == "BUILD_TYPE");
660        assert!(build_type.is_some());
661        assert!(build_type.unwrap().default.is_none());
662    }
663
664    // -- Multi-stage tests ------------------------------------------------
665
666    #[test]
667    fn test_multi_stage_basic() {
668        let df = parse_and_convert(
669            r#"
670stages:
671  builder:
672    base: "node:22-alpine"
673    workdir: "/src"
674    steps:
675      - copy: ["package.json", "package-lock.json"]
676        to: "./"
677      - run: "npm ci"
678      - copy: "."
679        to: "."
680      - run: "npm run build"
681  runtime:
682    base: "node:22-alpine"
683    workdir: "/app"
684    steps:
685      - copy: "dist"
686        from: builder
687        to: "/app"
688cmd: ["node", "dist/index.js"]
689expose: 3000
690"#,
691        );
692
693        assert_eq!(df.stages.len(), 2);
694
695        let builder = &df.stages[0];
696        assert_eq!(builder.name, Some("builder".to_string()));
697        assert_eq!(builder.index, 0);
698
699        let runtime = &df.stages[1];
700        assert_eq!(runtime.name, Some("runtime".to_string()));
701        assert_eq!(runtime.index, 1);
702
703        // The COPY --from=builder should be present.
704        let copy_from = runtime
705            .instructions
706            .iter()
707            .find(|i| matches!(i, Instruction::Copy(c) if c.from == Some("builder".to_string())));
708        assert!(copy_from.is_some());
709
710        // Top-level CMD and EXPOSE should be on the last stage.
711        let has_cmd = runtime
712            .instructions
713            .iter()
714            .any(|i| matches!(i, Instruction::Cmd(_)));
715        assert!(has_cmd);
716
717        let has_expose = runtime
718            .instructions
719            .iter()
720            .any(|i| matches!(i, Instruction::Expose(e) if e.port == 3000));
721        assert!(has_expose);
722    }
723
724    #[test]
725    fn test_multi_stage_cross_stage_base() {
726        let df = parse_and_convert(
727            r#"
728stages:
729  base:
730    base: "alpine:3.19"
731    steps:
732      - run: "apk add --no-cache curl"
733  derived:
734    base: "base"
735    steps:
736      - run: "echo derived"
737"#,
738        );
739
740        // The 'derived' stage should reference 'base' as ImageRef::Stage.
741        let derived = &df.stages[1];
742        assert!(matches!(&derived.base_image, ImageRef::Stage(name) if name == "base"));
743    }
744
745    // -- Step conversion tests --------------------------------------------
746
747    #[test]
748    fn test_step_run_with_cache() {
749        let df = parse_and_convert(
750            r#"
751base: "ubuntu:22.04"
752steps:
753  - run: "apt-get update && apt-get install -y curl"
754    cache:
755      - target: /var/cache/apt
756        id: apt-cache
757        sharing: shared
758      - target: /var/lib/apt
759        readonly: true
760"#,
761        );
762
763        let stage = &df.stages[0];
764        let run = stage
765            .instructions
766            .iter()
767            .find(|i| matches!(i, Instruction::Run(_)));
768        assert!(run.is_some());
769
770        if let Some(Instruction::Run(r)) = run {
771            assert_eq!(r.mounts.len(), 2);
772            assert!(matches!(
773                &r.mounts[0],
774                RunMount::Cache { target, id: Some(id), sharing: CacheSharing::Shared, readonly: false }
775                    if target == "/var/cache/apt" && id == "apt-cache"
776            ));
777            assert!(matches!(
778                &r.mounts[1],
779                RunMount::Cache { target, sharing: CacheSharing::Locked, readonly: true, .. }
780                    if target == "/var/lib/apt"
781            ));
782        }
783    }
784
785    #[test]
786    fn test_step_copy_with_options() {
787        let df = parse_and_convert(
788            r#"
789base: "alpine:3.19"
790steps:
791  - copy: "app.sh"
792    to: "/usr/local/bin/app.sh"
793    owner: "1000:1000"
794    chmod: "755"
795"#,
796        );
797
798        let stage = &df.stages[0];
799        if let Some(Instruction::Copy(c)) = stage.instructions.first() {
800            assert_eq!(c.sources, vec!["app.sh"]);
801            assert_eq!(c.destination, "/usr/local/bin/app.sh");
802            assert_eq!(c.chown, Some("1000:1000".to_string()));
803            assert_eq!(c.chmod, Some("755".to_string()));
804        } else {
805            panic!("Expected COPY instruction");
806        }
807    }
808
809    #[test]
810    fn test_step_add() {
811        let df = parse_and_convert(
812            r#"
813base: "alpine:3.19"
814steps:
815  - add: "https://example.com/file.tar.gz"
816    to: "/app/"
817"#,
818        );
819
820        let stage = &df.stages[0];
821        if let Some(Instruction::Add(a)) = stage.instructions.first() {
822            assert_eq!(a.sources, vec!["https://example.com/file.tar.gz"]);
823            assert_eq!(a.destination, "/app/");
824        } else {
825            panic!("Expected ADD instruction");
826        }
827    }
828
829    #[test]
830    fn test_step_env() {
831        let df = parse_and_convert(
832            r#"
833base: "alpine:3.19"
834steps:
835  - env:
836      FOO: bar
837      BAZ: qux
838"#,
839        );
840
841        let stage = &df.stages[0];
842        if let Some(Instruction::Env(e)) = stage.instructions.first() {
843            assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
844            assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
845        } else {
846            panic!("Expected ENV instruction");
847        }
848    }
849
850    #[test]
851    fn test_expose_multiple_with_protocol() {
852        let df = parse_and_convert(
853            r#"
854base: "alpine:3.19"
855expose:
856  - 8080
857  - "9090/udp"
858"#,
859        );
860
861        let stage = &df.stages[0];
862        let exposes: Vec<&ExposeInstruction> = stage
863            .instructions
864            .iter()
865            .filter_map(|i| match i {
866                Instruction::Expose(e) => Some(e),
867                _ => None,
868            })
869            .collect();
870
871        assert_eq!(exposes.len(), 2);
872        assert_eq!(exposes[0].port, 8080);
873        assert!(matches!(
874            exposes[0].protocol,
875            crate::dockerfile::ExposeProtocol::Tcp
876        ));
877        assert_eq!(exposes[1].port, 9090);
878        assert!(matches!(
879            exposes[1].protocol,
880            crate::dockerfile::ExposeProtocol::Udp
881        ));
882    }
883
884    #[test]
885    fn test_volumes_and_stopsignal() {
886        let df = parse_and_convert(
887            r#"
888base: "alpine:3.19"
889volumes:
890  - /data
891  - /logs
892stopsignal: SIGTERM
893"#,
894        );
895
896        let stage = &df.stages[0];
897        let has_volume = stage.instructions.iter().any(|i| {
898            matches!(i, Instruction::Volume(v) if v.len() == 2 && v.contains(&"/data".to_string()))
899        });
900        assert!(has_volume);
901
902        let has_signal = stage
903            .instructions
904            .iter()
905            .any(|i| matches!(i, Instruction::Stopsignal(s) if s == "SIGTERM"));
906        assert!(has_signal);
907    }
908
909    #[test]
910    fn test_entrypoint_and_cmd() {
911        let df = parse_and_convert(
912            r#"
913base: "alpine:3.19"
914entrypoint: ["/docker-entrypoint.sh"]
915cmd: ["node", "server.js"]
916"#,
917        );
918
919        let stage = &df.stages[0];
920        let has_ep = stage.instructions.iter().any(|i| {
921            matches!(i, Instruction::Entrypoint(ShellOrExec::Exec(v)) if v == &["/docker-entrypoint.sh"])
922        });
923        assert!(has_ep);
924
925        let has_cmd = stage.instructions.iter().any(
926            |i| matches!(i, Instruction::Cmd(ShellOrExec::Exec(v)) if v == &["node", "server.js"]),
927        );
928        assert!(has_cmd);
929    }
930
931    #[test]
932    fn test_user_instruction() {
933        let df = parse_and_convert(
934            r#"
935base: "alpine:3.19"
936user: "nobody"
937"#,
938        );
939
940        let stage = &df.stages[0];
941        let has_user = stage
942            .instructions
943            .iter()
944            .any(|i| matches!(i, Instruction::User(u) if u == "nobody"));
945        assert!(has_user);
946    }
947
948    #[test]
949    fn test_scratch_base() {
950        let df = parse_and_convert(
951            r#"
952base: scratch
953cmd: ["/app"]
954"#,
955        );
956
957        assert!(matches!(&df.stages[0].base_image, ImageRef::Scratch));
958    }
959
960    #[test]
961    fn test_runtime_mode_not_convertible() {
962        let yaml = r#"
963runtime: node22
964cmd: "node server.js"
965"#;
966        let zimage = parse_zimagefile(yaml).unwrap();
967        let result = zimage_to_dockerfile(&zimage);
968        assert!(result.is_err());
969    }
970
971    #[test]
972    fn test_invalid_healthcheck_duration() {
973        let yaml = r#"
974base: "alpine:3.19"
975healthcheck:
976  cmd: "true"
977  interval: "not_a_duration"
978"#;
979        let zimage = parse_zimagefile(yaml).unwrap();
980        let result = zimage_to_dockerfile(&zimage);
981        assert!(result.is_err());
982        let msg = result.unwrap_err().to_string();
983        assert!(msg.contains("interval"), "got: {msg}");
984    }
985}