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