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