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 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
388fn 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
418fn 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
429fn 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
470fn 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#[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 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#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::zimage::parse_zimagefile;
536
537 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 #[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 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 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 #[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 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 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 let derived = &df.stages[1];
748 assert!(matches!(&derived.base_image, DockerfileFromTarget::Stage(name) if name == "base"));
749 }
750
751 #[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 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 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 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}