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