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    let env = step.env.clone().unwrap_or_default();
378
379    Instruction::Run(RunInstruction {
380        command,
381        mounts,
382        network: None,
383        security: None,
384        env,
385    })
386}
387
388// ---------------------------------------------------------------------------
389// COPY / ADD
390// ---------------------------------------------------------------------------
391
392fn convert_copy(sources: &super::types::ZCopySources, step: &ZStep) -> Instruction {
393    let destination = step.to.clone().unwrap_or_default();
394    Instruction::Copy(CopyInstruction {
395        sources: sources.to_vec(),
396        destination,
397        from: step.from.clone(),
398        chown: step.owner.clone(),
399        chmod: step.chmod.clone(),
400        link: false,
401        exclude: Vec::new(),
402    })
403}
404
405fn convert_add(sources: &super::types::ZCopySources, step: &ZStep) -> Instruction {
406    let destination = step.to.clone().unwrap_or_default();
407    Instruction::Add(AddInstruction {
408        sources: sources.to_vec(),
409        destination,
410        chown: step.owner.clone(),
411        chmod: step.chmod.clone(),
412        link: false,
413        checksum: None,
414        keep_git_dir: false,
415    })
416}
417
418// ---------------------------------------------------------------------------
419// Command helper
420// ---------------------------------------------------------------------------
421
422fn convert_command(cmd: &ZCommand) -> ShellOrExec {
423    match cmd {
424        ZCommand::Shell(s) => ShellOrExec::Shell(s.clone()),
425        ZCommand::Exec(v) => ShellOrExec::Exec(v.clone()),
426    }
427}
428
429// ---------------------------------------------------------------------------
430// Expose
431// ---------------------------------------------------------------------------
432
433fn convert_expose(expose: &ZExpose) -> Result<Vec<Instruction>> {
434    match expose {
435        ZExpose::Single(port) => Ok(vec![Instruction::Expose(ExposeInstruction::tcp(*port))]),
436        ZExpose::Multiple(specs) => {
437            let mut out = Vec::with_capacity(specs.len());
438            for spec in specs {
439                out.push(convert_port_spec(spec)?);
440            }
441            Ok(out)
442        }
443    }
444}
445
446fn convert_port_spec(spec: &ZPortSpec) -> Result<Instruction> {
447    match spec {
448        ZPortSpec::Number(port) => Ok(Instruction::Expose(ExposeInstruction::tcp(*port))),
449        ZPortSpec::WithProtocol(s) => {
450            let (port_str, proto_str) = s.split_once('/').ok_or_else(|| {
451                BuildError::zimagefile_validation(format!(
452                    "invalid port spec '{s}', expected format '<port>/<protocol>'"
453                ))
454            })?;
455
456            let port: u16 = port_str.parse().map_err(|_| {
457                BuildError::zimagefile_validation(format!("invalid port number: '{port_str}'"))
458            })?;
459
460            let inst = match proto_str.to_lowercase().as_str() {
461                "udp" => ExposeInstruction::udp(port),
462                _ => ExposeInstruction::tcp(port),
463            };
464
465            Ok(Instruction::Expose(inst))
466        }
467    }
468}
469
470// ---------------------------------------------------------------------------
471// Healthcheck
472// ---------------------------------------------------------------------------
473
474fn convert_healthcheck(hc: &ZHealthcheck) -> Result<Instruction> {
475    let command = convert_command(&hc.cmd);
476
477    let interval = parse_optional_duration(hc.interval.as_ref(), "healthcheck interval")?;
478    let timeout = parse_optional_duration(hc.timeout.as_ref(), "healthcheck timeout")?;
479    let start_period =
480        parse_optional_duration(hc.start_period.as_ref(), "healthcheck start_period")?;
481
482    Ok(Instruction::Healthcheck(HealthcheckInstruction::Check {
483        command,
484        interval,
485        timeout,
486        start_period,
487        start_interval: None,
488        retries: hc.retries,
489    }))
490}
491
492fn parse_optional_duration(
493    value: Option<&String>,
494    label: &str,
495) -> Result<Option<std::time::Duration>> {
496    match value {
497        None => Ok(None),
498        Some(s) => {
499            let dur = humantime::parse_duration(s).map_err(|e| {
500                BuildError::zimagefile_validation(format!("invalid {label} '{s}': {e}"))
501            })?;
502            Ok(Some(dur))
503        }
504    }
505}
506
507// ---------------------------------------------------------------------------
508// Cache mount
509// ---------------------------------------------------------------------------
510
511#[must_use]
512pub fn convert_cache_mount(cm: &ZCacheMount) -> RunMount {
513    let sharing = match cm.sharing.as_deref() {
514        Some("shared") => CacheSharing::Shared,
515        Some("private") => CacheSharing::Private,
516        // "locked", unknown values, or None all fall back to the default.
517        Some(_) | None => CacheSharing::Locked,
518    };
519
520    RunMount::Cache {
521        target: cm.target.clone(),
522        id: cm.id.clone(),
523        sharing,
524        readonly: cm.readonly,
525    }
526}
527
528// ---------------------------------------------------------------------------
529// Tests
530// ---------------------------------------------------------------------------
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::zimage::parse_zimagefile;
536
537    // -- Helpers ----------------------------------------------------------
538
539    fn parse_and_convert(yaml: &str) -> Dockerfile {
540        let zimage = parse_zimagefile(yaml).expect("YAML parse failed");
541        zimage_to_dockerfile(&zimage).expect("conversion failed")
542    }
543
544    // -- Single-stage tests -----------------------------------------------
545
546    #[test]
547    fn test_single_stage_basic() {
548        let df = parse_and_convert(
549            r#"
550base: "alpine:3.19"
551steps:
552  - run: "apk add --no-cache curl"
553  - copy: "app.sh"
554    to: "/usr/local/bin/app.sh"
555    chmod: "755"
556  - workdir: "/app"
557cmd: ["./app.sh"]
558"#,
559        );
560
561        assert_eq!(df.stages.len(), 1);
562        let stage = &df.stages[0];
563        assert_eq!(stage.index, 0);
564        assert!(stage.name.is_none());
565
566        // base image
567        match &stage.base_image {
568            DockerfileFromTarget::Image(r) => {
569                assert!(r.repository().contains("alpine"));
570                assert_eq!(r.tag(), Some("3.19"));
571            }
572            other => panic!("expected DockerfileFromTarget::Image, got {other:?}"),
573        }
574
575        // Should contain RUN, COPY, WORKDIR, CMD
576        let names: Vec<&str> = stage
577            .instructions
578            .iter()
579            .map(crate::Instruction::name)
580            .collect();
581        assert!(names.contains(&"RUN"));
582        assert!(names.contains(&"COPY"));
583        assert!(names.contains(&"WORKDIR"));
584        assert!(names.contains(&"CMD"));
585    }
586
587    #[test]
588    fn test_single_stage_env_and_expose() {
589        let df = parse_and_convert(
590            r#"
591base: "node:22-alpine"
592env:
593  NODE_ENV: production
594expose: 3000
595cmd: "node server.js"
596"#,
597        );
598
599        let stage = &df.stages[0];
600        let has_env = stage.instructions.iter().any(|i| matches!(i, Instruction::Env(e) if e.vars.get("NODE_ENV") == Some(&"production".to_string())));
601        assert!(has_env);
602
603        let has_expose = stage
604            .instructions
605            .iter()
606            .any(|i| matches!(i, Instruction::Expose(e) if e.port == 3000));
607        assert!(has_expose);
608    }
609
610    #[test]
611    fn test_single_stage_healthcheck() {
612        let df = parse_and_convert(
613            r#"
614base: "alpine:3.19"
615healthcheck:
616  cmd: "curl -f http://localhost/ || exit 1"
617  interval: "30s"
618  timeout: "10s"
619  start_period: "5s"
620  retries: 3
621"#,
622        );
623
624        let stage = &df.stages[0];
625        let hc = stage
626            .instructions
627            .iter()
628            .find(|i| matches!(i, Instruction::Healthcheck(_)));
629        assert!(hc.is_some());
630
631        if let Some(Instruction::Healthcheck(HealthcheckInstruction::Check {
632            interval,
633            timeout,
634            start_period,
635            retries,
636            ..
637        })) = hc
638        {
639            assert_eq!(*interval, Some(std::time::Duration::from_secs(30)));
640            assert_eq!(*timeout, Some(std::time::Duration::from_secs(10)));
641            assert_eq!(*start_period, Some(std::time::Duration::from_secs(5)));
642            assert_eq!(*retries, Some(3));
643        } else {
644            panic!("Expected Healthcheck::Check");
645        }
646    }
647
648    #[test]
649    fn test_global_args() {
650        let df = parse_and_convert(
651            r#"
652base: "alpine:3.19"
653args:
654  VERSION: "1.0"
655  BUILD_TYPE: ""
656"#,
657        );
658
659        assert_eq!(df.global_args.len(), 2);
660
661        let version = df.global_args.iter().find(|a| a.name == "VERSION");
662        assert!(version.is_some());
663        assert_eq!(version.unwrap().default, Some("1.0".to_string()));
664
665        let build_type = df.global_args.iter().find(|a| a.name == "BUILD_TYPE");
666        assert!(build_type.is_some());
667        assert!(build_type.unwrap().default.is_none());
668    }
669
670    // -- Multi-stage tests ------------------------------------------------
671
672    #[test]
673    fn test_multi_stage_basic() {
674        let df = parse_and_convert(
675            r#"
676stages:
677  builder:
678    base: "node:22-alpine"
679    workdir: "/src"
680    steps:
681      - copy: ["package.json", "package-lock.json"]
682        to: "./"
683      - run: "npm ci"
684      - copy: "."
685        to: "."
686      - run: "npm run build"
687  runtime:
688    base: "node:22-alpine"
689    workdir: "/app"
690    steps:
691      - copy: "dist"
692        from: builder
693        to: "/app"
694cmd: ["node", "dist/index.js"]
695expose: 3000
696"#,
697        );
698
699        assert_eq!(df.stages.len(), 2);
700
701        let builder = &df.stages[0];
702        assert_eq!(builder.name, Some("builder".to_string()));
703        assert_eq!(builder.index, 0);
704
705        let runtime = &df.stages[1];
706        assert_eq!(runtime.name, Some("runtime".to_string()));
707        assert_eq!(runtime.index, 1);
708
709        // The COPY --from=builder should be present.
710        let copy_from = runtime
711            .instructions
712            .iter()
713            .find(|i| matches!(i, Instruction::Copy(c) if c.from == Some("builder".to_string())));
714        assert!(copy_from.is_some());
715
716        // Top-level CMD and EXPOSE should be on the last stage.
717        let has_cmd = runtime
718            .instructions
719            .iter()
720            .any(|i| matches!(i, Instruction::Cmd(_)));
721        assert!(has_cmd);
722
723        let has_expose = runtime
724            .instructions
725            .iter()
726            .any(|i| matches!(i, Instruction::Expose(e) if e.port == 3000));
727        assert!(has_expose);
728    }
729
730    #[test]
731    fn test_multi_stage_cross_stage_base() {
732        let df = parse_and_convert(
733            r#"
734stages:
735  base:
736    base: "alpine:3.19"
737    steps:
738      - run: "apk add --no-cache curl"
739  derived:
740    base: "base"
741    steps:
742      - run: "echo derived"
743"#,
744        );
745
746        // The 'derived' stage should reference 'base' as DockerfileFromTarget::Stage.
747        let derived = &df.stages[1];
748        assert!(matches!(&derived.base_image, DockerfileFromTarget::Stage(name) if name == "base"));
749    }
750
751    // -- Step conversion tests --------------------------------------------
752
753    #[test]
754    fn test_step_run_with_cache() {
755        let df = parse_and_convert(
756            r#"
757base: "ubuntu:22.04"
758steps:
759  - run: "apt-get update && apt-get install -y curl"
760    cache:
761      - target: /var/cache/apt
762        id: apt-cache
763        sharing: shared
764      - target: /var/lib/apt
765        readonly: true
766"#,
767        );
768
769        let stage = &df.stages[0];
770        let run = stage
771            .instructions
772            .iter()
773            .find(|i| matches!(i, Instruction::Run(_)));
774        assert!(run.is_some());
775
776        if let Some(Instruction::Run(r)) = run {
777            assert_eq!(r.mounts.len(), 2);
778            assert!(matches!(
779                &r.mounts[0],
780                RunMount::Cache { target, id: Some(id), sharing: CacheSharing::Shared, readonly: false }
781                    if target == "/var/cache/apt" && id == "apt-cache"
782            ));
783            assert!(matches!(
784                &r.mounts[1],
785                RunMount::Cache { target, sharing: CacheSharing::Locked, readonly: true, .. }
786                    if target == "/var/lib/apt"
787            ));
788        }
789    }
790
791    #[test]
792    fn test_step_copy_with_options() {
793        let df = parse_and_convert(
794            r#"
795base: "alpine:3.19"
796steps:
797  - copy: "app.sh"
798    to: "/usr/local/bin/app.sh"
799    owner: "1000:1000"
800    chmod: "755"
801"#,
802        );
803
804        let stage = &df.stages[0];
805        if let Some(Instruction::Copy(c)) = stage.instructions.first() {
806            assert_eq!(c.sources, vec!["app.sh"]);
807            assert_eq!(c.destination, "/usr/local/bin/app.sh");
808            assert_eq!(c.chown, Some("1000:1000".to_string()));
809            assert_eq!(c.chmod, Some("755".to_string()));
810        } else {
811            panic!("Expected COPY instruction");
812        }
813    }
814
815    #[test]
816    fn test_step_add() {
817        let df = parse_and_convert(
818            r#"
819base: "alpine:3.19"
820steps:
821  - add: "https://example.com/file.tar.gz"
822    to: "/app/"
823"#,
824        );
825
826        let stage = &df.stages[0];
827        if let Some(Instruction::Add(a)) = stage.instructions.first() {
828            assert_eq!(a.sources, vec!["https://example.com/file.tar.gz"]);
829            assert_eq!(a.destination, "/app/");
830        } else {
831            panic!("Expected ADD instruction");
832        }
833    }
834
835    #[test]
836    fn test_step_run_with_inline_env() {
837        // When a step has BOTH `run` and `env`, the env must be threaded
838        // into the RunInstruction (transient `--env=K=V` flags) rather than
839        // emitted as a separate Instruction::Env (which would persist into
840        // the image config).
841        let df = parse_and_convert(
842            r#"
843base: "alpine:3.19"
844steps:
845  - run: "env | grep -E '^(A|B)='"
846    env:
847      A: "1"
848      B: "2"
849"#,
850        );
851
852        let stage = &df.stages[0];
853
854        // Exactly one Instruction::Env should be absent for this step — only
855        // the RUN instruction should appear.
856        let env_count = stage
857            .instructions
858            .iter()
859            .filter(|i| matches!(i, Instruction::Env(_)))
860            .count();
861        assert_eq!(
862            env_count, 0,
863            "step with run+env must NOT emit a separate ENV instruction"
864        );
865
866        // The RUN instruction should carry the env map.
867        let run = stage
868            .instructions
869            .iter()
870            .find_map(|i| match i {
871                Instruction::Run(r) => Some(r),
872                _ => None,
873            })
874            .expect("expected a RUN instruction");
875
876        assert_eq!(run.env.get("A"), Some(&"1".to_string()));
877        assert_eq!(run.env.get("B"), Some(&"2".to_string()));
878        assert_eq!(run.env.len(), 2);
879    }
880
881    #[test]
882    fn test_step_env() {
883        let df = parse_and_convert(
884            r#"
885base: "alpine:3.19"
886steps:
887  - env:
888      FOO: bar
889      BAZ: qux
890"#,
891        );
892
893        let stage = &df.stages[0];
894        if let Some(Instruction::Env(e)) = stage.instructions.first() {
895            assert_eq!(e.vars.get("FOO"), Some(&"bar".to_string()));
896            assert_eq!(e.vars.get("BAZ"), Some(&"qux".to_string()));
897        } else {
898            panic!("Expected ENV instruction");
899        }
900    }
901
902    #[test]
903    fn test_expose_multiple_with_protocol() {
904        let df = parse_and_convert(
905            r#"
906base: "alpine:3.19"
907expose:
908  - 8080
909  - "9090/udp"
910"#,
911        );
912
913        let stage = &df.stages[0];
914        let exposes: Vec<&ExposeInstruction> = stage
915            .instructions
916            .iter()
917            .filter_map(|i| match i {
918                Instruction::Expose(e) => Some(e),
919                _ => None,
920            })
921            .collect();
922
923        assert_eq!(exposes.len(), 2);
924        assert_eq!(exposes[0].port, 8080);
925        assert!(matches!(
926            exposes[0].protocol,
927            crate::dockerfile::ExposeProtocol::Tcp
928        ));
929        assert_eq!(exposes[1].port, 9090);
930        assert!(matches!(
931            exposes[1].protocol,
932            crate::dockerfile::ExposeProtocol::Udp
933        ));
934    }
935
936    #[test]
937    fn test_volumes_and_stopsignal() {
938        let df = parse_and_convert(
939            r#"
940base: "alpine:3.19"
941volumes:
942  - /data
943  - /logs
944stopsignal: SIGTERM
945"#,
946        );
947
948        let stage = &df.stages[0];
949        let has_volume = stage.instructions.iter().any(|i| {
950            matches!(i, Instruction::Volume(v) if v.len() == 2 && v.contains(&"/data".to_string()))
951        });
952        assert!(has_volume);
953
954        let has_signal = stage
955            .instructions
956            .iter()
957            .any(|i| matches!(i, Instruction::Stopsignal(s) if s == "SIGTERM"));
958        assert!(has_signal);
959    }
960
961    #[test]
962    fn test_entrypoint_and_cmd() {
963        let df = parse_and_convert(
964            r#"
965base: "alpine:3.19"
966entrypoint: ["/docker-entrypoint.sh"]
967cmd: ["node", "server.js"]
968"#,
969        );
970
971        let stage = &df.stages[0];
972        let has_ep = stage.instructions.iter().any(|i| {
973            matches!(i, Instruction::Entrypoint(ShellOrExec::Exec(v)) if v == &["/docker-entrypoint.sh"])
974        });
975        assert!(has_ep);
976
977        let has_cmd = stage.instructions.iter().any(
978            |i| matches!(i, Instruction::Cmd(ShellOrExec::Exec(v)) if v == &["node", "server.js"]),
979        );
980        assert!(has_cmd);
981    }
982
983    #[test]
984    fn test_user_instruction() {
985        let df = parse_and_convert(
986            r#"
987base: "alpine:3.19"
988user: "nobody"
989"#,
990        );
991
992        let stage = &df.stages[0];
993        let has_user = stage
994            .instructions
995            .iter()
996            .any(|i| matches!(i, Instruction::User(u) if u == "nobody"));
997        assert!(has_user);
998    }
999
1000    #[test]
1001    fn test_scratch_base() {
1002        let df = parse_and_convert(
1003            r#"
1004base: scratch
1005cmd: ["/app"]
1006"#,
1007        );
1008
1009        assert!(matches!(
1010            &df.stages[0].base_image,
1011            DockerfileFromTarget::Scratch
1012        ));
1013    }
1014
1015    #[test]
1016    fn test_runtime_mode_not_convertible() {
1017        let yaml = r#"
1018runtime: node22
1019cmd: "node server.js"
1020"#;
1021        let zimage = parse_zimagefile(yaml).unwrap();
1022        let result = zimage_to_dockerfile(&zimage);
1023        assert!(result.is_err());
1024    }
1025
1026    #[test]
1027    fn test_invalid_healthcheck_duration() {
1028        let yaml = r#"
1029base: "alpine:3.19"
1030healthcheck:
1031  cmd: "true"
1032  interval: "not_a_duration"
1033"#;
1034        let zimage = parse_zimagefile(yaml).unwrap();
1035        let result = zimage_to_dockerfile(&zimage);
1036        assert!(result.is_err());
1037        let msg = result.unwrap_err().to_string();
1038        assert!(msg.contains("interval"), "got: {msg}");
1039    }
1040}