1use 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
20use crate::dockerfile::Dockerfile;
22
23use crate::dockerfile::CacheSharing;
25
26pub fn zimage_to_dockerfile(zimage: &ZImage) -> Result<Dockerfile> {
46 let global_args = convert_global_args(&zimage.args);
48
49 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
73fn 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 result.sort_by(|a, b| a.name.cmp(&b.name));
90 result
91}
92
93fn convert_single_stage(zimage: &ZImage, base: &str) -> Result<Stage> {
98 let base_image = DockerfileFromTarget::parse(base);
99 let mut instructions = Vec::new();
100
101 if !zimage.env.is_empty() {
103 instructions.push(Instruction::Env(EnvInstruction::from_vars(
104 zimage.env.clone(),
105 )));
106 }
107
108 if let Some(ref wd) = zimage.workdir {
110 instructions.push(Instruction::Workdir(wd.clone()));
111 }
112
113 for step in &zimage.steps {
115 instructions.push(convert_step(step)?);
116 }
117
118 if !zimage.labels.is_empty() {
120 instructions.push(Instruction::Label(zimage.labels.clone()));
121 }
122
123 if let Some(ref expose) = zimage.expose {
125 instructions.extend(convert_expose(expose)?);
126 }
127
128 if let Some(ref user) = zimage.user {
130 instructions.push(Instruction::User(user.clone()));
131 }
132
133 if !zimage.volumes.is_empty() {
135 instructions.push(Instruction::Volume(zimage.volumes.clone()));
136 }
137
138 if let Some(ref hc) = zimage.healthcheck {
140 instructions.push(convert_healthcheck(hc)?);
141 }
142
143 if let Some(ref sig) = zimage.stopsignal {
145 instructions.push(Instruction::Stopsignal(sig.clone()));
146 }
147
148 if let Some(ref ep) = zimage.entrypoint {
150 instructions.push(Instruction::Entrypoint(convert_command(ep)));
151 }
152
153 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#[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 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 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 if !zstage.env.is_empty() {
216 instructions.push(Instruction::Env(EnvInstruction::from_vars(
217 zstage.env.clone(),
218 )));
219 }
220
221 if let Some(ref wd) = zstage.workdir {
223 instructions.push(Instruction::Workdir(wd.clone()));
224 }
225
226 for step in &zstage.steps {
228 instructions.push(convert_step(step)?);
229 }
230
231 if !zstage.labels.is_empty() {
233 instructions.push(Instruction::Label(zstage.labels.clone()));
234 }
235
236 if let Some(ref expose) = zstage.expose {
238 instructions.extend(convert_expose(expose)?);
239 }
240
241 if let Some(ref user) = zstage.user {
243 instructions.push(Instruction::User(user.clone()));
244 }
245
246 if !zstage.volumes.is_empty() {
248 instructions.push(Instruction::Volume(zstage.volumes.clone()));
249 }
250
251 if let Some(ref hc) = zstage.healthcheck {
253 instructions.push(convert_healthcheck(hc)?);
254 }
255
256 if let Some(ref sig) = zstage.stopsignal {
258 instructions.push(Instruction::Stopsignal(sig.clone()));
259 }
260
261 if let Some(ref ep) = zstage.entrypoint {
263 instructions.push(Instruction::Entrypoint(convert_command(ep)));
264 }
265
266 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 if let Some(last) = stages.last_mut() {
282 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
334fn 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 Err(BuildError::zimagefile_validation(
366 "step has no recognised instruction (run, copy, add, env, workdir, user)",
367 ))
368}
369
370fn 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
386fn 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
416fn 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
427fn 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
468fn 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#[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 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#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::zimage::parse_zimagefile;
534
535 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 #[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 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 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 #[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 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 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 let derived = &df.stages[1];
746 assert!(matches!(&derived.base_image, DockerfileFromTarget::Stage(name) if name == "base"));
747 }
748
749 #[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}