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