1mod executor;
26mod install;
27
28pub use executor::*;
29pub use install::{
30 current_platform, install_instructions, is_platform_supported, BuildahInstallation,
31 BuildahInstaller, InstallError,
32};
33
34use crate::dockerfile::{
35 AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction, HealthcheckInstruction,
36 Instruction, RunInstruction, ShellOrExec,
37};
38
39use std::collections::HashMap;
40
41#[derive(Debug, Clone)]
43pub struct BuildahCommand {
44 pub program: String,
46
47 pub args: Vec<String>,
49
50 pub env: HashMap<String, String>,
52}
53
54impl BuildahCommand {
55 pub fn new(subcommand: &str) -> Self {
57 Self {
58 program: "buildah".to_string(),
59 args: vec![subcommand.to_string()],
60 env: HashMap::new(),
61 }
62 }
63
64 pub fn arg(mut self, arg: impl Into<String>) -> Self {
66 self.args.push(arg.into());
67 self
68 }
69
70 pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
72 self.args.extend(args.into_iter().map(Into::into));
73 self
74 }
75
76 pub fn arg_opt(self, flag: &str, value: Option<impl Into<String>>) -> Self {
78 if let Some(v) = value {
79 self.arg(flag).arg(v)
80 } else {
81 self
82 }
83 }
84
85 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
87 self.env.insert(key.into(), value.into());
88 self
89 }
90
91 pub fn to_command_string(&self) -> String {
93 let mut parts = vec![self.program.clone()];
94 parts.extend(self.args.iter().map(|a| {
95 if a.contains(' ') || a.contains('"') {
96 format!("\"{}\"", a.replace('"', "\\\""))
97 } else {
98 a.clone()
99 }
100 }));
101 parts.join(" ")
102 }
103
104 pub fn from_image(image: &str) -> Self {
112 Self::new("from").arg(image)
113 }
114
115 pub fn from_image_named(image: &str, name: &str) -> Self {
119 Self::new("from").arg("--name").arg(name).arg(image)
120 }
121
122 pub fn from_scratch() -> Self {
126 Self::new("from").arg("scratch")
127 }
128
129 pub fn rm(container: &str) -> Self {
133 Self::new("rm").arg(container)
134 }
135
136 pub fn commit(container: &str, image_name: &str) -> Self {
140 Self::new("commit").arg(container).arg(image_name)
141 }
142
143 pub fn commit_with_opts(
145 container: &str,
146 image_name: &str,
147 format: Option<&str>,
148 squash: bool,
149 ) -> Self {
150 let mut cmd = Self::new("commit");
151
152 if let Some(fmt) = format {
153 cmd = cmd.arg("--format").arg(fmt);
154 }
155
156 if squash {
157 cmd = cmd.arg("--squash");
158 }
159
160 cmd.arg(container).arg(image_name)
161 }
162
163 pub fn tag(image: &str, new_name: &str) -> Self {
167 Self::new("tag").arg(image).arg(new_name)
168 }
169
170 pub fn rmi(image: &str) -> Self {
174 Self::new("rmi").arg(image)
175 }
176
177 pub fn push(image: &str) -> Self {
181 Self::new("push").arg(image)
182 }
183
184 pub fn push_to(image: &str, destination: &str) -> Self {
188 Self::new("push").arg(image).arg(destination)
189 }
190
191 pub fn inspect(name: &str) -> Self {
195 Self::new("inspect").arg(name)
196 }
197
198 pub fn inspect_format(name: &str, format: &str) -> Self {
202 Self::new("inspect").arg("--format").arg(format).arg(name)
203 }
204
205 pub fn images() -> Self {
209 Self::new("images")
210 }
211
212 pub fn containers() -> Self {
216 Self::new("containers")
217 }
218
219 pub fn run_shell(container: &str, command: &str) -> Self {
227 Self::new("run")
228 .arg(container)
229 .arg("--")
230 .arg("/bin/sh")
231 .arg("-c")
232 .arg(command)
233 }
234
235 pub fn run_exec(container: &str, args: &[String]) -> Self {
239 let mut cmd = Self::new("run").arg(container).arg("--");
240 for arg in args {
241 cmd = cmd.arg(arg);
242 }
243 cmd
244 }
245
246 pub fn run(container: &str, command: &ShellOrExec) -> Self {
248 match command {
249 ShellOrExec::Shell(s) => Self::run_shell(container, s),
250 ShellOrExec::Exec(args) => Self::run_exec(container, args),
251 }
252 }
253
254 pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
261 let mut cmd = Self::new("run");
262
263 for mount in &run.mounts {
265 cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
266 }
267
268 cmd = cmd.arg(container).arg("--");
270
271 match &run.command {
272 ShellOrExec::Shell(s) => cmd.arg("/bin/sh").arg("-c").arg(s),
273 ShellOrExec::Exec(args) => {
274 for arg in args {
275 cmd = cmd.arg(arg);
276 }
277 cmd
278 }
279 }
280 }
281
282 pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
290 let mut cmd = Self::new("copy").arg(container);
291 for src in sources {
292 cmd = cmd.arg(src);
293 }
294 cmd.arg(dest)
295 }
296
297 pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
301 let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
302 for src in sources {
303 cmd = cmd.arg(src);
304 }
305 cmd.arg(dest)
306 }
307
308 pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
310 let mut cmd = Self::new("copy");
311
312 if let Some(ref from) = copy.from {
313 cmd = cmd.arg("--from").arg(from);
314 }
315
316 if let Some(ref chown) = copy.chown {
317 cmd = cmd.arg("--chown").arg(chown);
318 }
319
320 if let Some(ref chmod) = copy.chmod {
321 cmd = cmd.arg("--chmod").arg(chmod);
322 }
323
324 cmd = cmd.arg(container);
325
326 for src in ©.sources {
327 cmd = cmd.arg(src);
328 }
329
330 cmd.arg(©.destination)
331 }
332
333 pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
335 let mut cmd = Self::new("add").arg(container);
336 for src in sources {
337 cmd = cmd.arg(src);
338 }
339 cmd.arg(dest)
340 }
341
342 pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
344 let mut cmd = Self::new("add");
345
346 if let Some(ref chown) = add.chown {
347 cmd = cmd.arg("--chown").arg(chown);
348 }
349
350 if let Some(ref chmod) = add.chmod {
351 cmd = cmd.arg("--chmod").arg(chmod);
352 }
353
354 cmd = cmd.arg(container);
355
356 for src in &add.sources {
357 cmd = cmd.arg(src);
358 }
359
360 cmd.arg(&add.destination)
361 }
362
363 pub fn config_env(container: &str, key: &str, value: &str) -> Self {
371 Self::new("config")
372 .arg("--env")
373 .arg(format!("{}={}", key, value))
374 .arg(container)
375 }
376
377 pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
379 env.vars
380 .iter()
381 .map(|(k, v)| Self::config_env(container, k, v))
382 .collect()
383 }
384
385 pub fn config_workdir(container: &str, dir: &str) -> Self {
389 Self::new("config")
390 .arg("--workingdir")
391 .arg(dir)
392 .arg(container)
393 }
394
395 pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
399 let port_spec = format!(
400 "{}/{}",
401 expose.port,
402 match expose.protocol {
403 crate::dockerfile::ExposeProtocol::Tcp => "tcp",
404 crate::dockerfile::ExposeProtocol::Udp => "udp",
405 }
406 );
407 Self::new("config")
408 .arg("--port")
409 .arg(port_spec)
410 .arg(container)
411 }
412
413 pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
417 Self::new("config")
418 .arg("--entrypoint")
419 .arg(format!(
420 "[\"/bin/sh\", \"-c\", \"{}\"]",
421 escape_json_string(command)
422 ))
423 .arg(container)
424 }
425
426 pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
430 let json_array = format!(
431 "[{}]",
432 args.iter()
433 .map(|a| format!("\"{}\"", escape_json_string(a)))
434 .collect::<Vec<_>>()
435 .join(", ")
436 );
437 Self::new("config")
438 .arg("--entrypoint")
439 .arg(json_array)
440 .arg(container)
441 }
442
443 pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
445 match command {
446 ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
447 ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
448 }
449 }
450
451 pub fn config_cmd_shell(container: &str, command: &str) -> Self {
453 Self::new("config")
454 .arg("--cmd")
455 .arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
456 .arg(container)
457 }
458
459 pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
461 let json_array = format!(
462 "[{}]",
463 args.iter()
464 .map(|a| format!("\"{}\"", escape_json_string(a)))
465 .collect::<Vec<_>>()
466 .join(", ")
467 );
468 Self::new("config")
469 .arg("--cmd")
470 .arg(json_array)
471 .arg(container)
472 }
473
474 pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
476 match command {
477 ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
478 ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
479 }
480 }
481
482 pub fn config_user(container: &str, user: &str) -> Self {
486 Self::new("config").arg("--user").arg(user).arg(container)
487 }
488
489 pub fn config_label(container: &str, key: &str, value: &str) -> Self {
493 Self::new("config")
494 .arg("--label")
495 .arg(format!("{}={}", key, value))
496 .arg(container)
497 }
498
499 pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
501 labels
502 .iter()
503 .map(|(k, v)| Self::config_label(container, k, v))
504 .collect()
505 }
506
507 pub fn config_volume(container: &str, path: &str) -> Self {
511 Self::new("config").arg("--volume").arg(path).arg(container)
512 }
513
514 pub fn config_stopsignal(container: &str, signal: &str) -> Self {
518 Self::new("config")
519 .arg("--stop-signal")
520 .arg(signal)
521 .arg(container)
522 }
523
524 pub fn config_shell(container: &str, shell: &[String]) -> Self {
528 let json_array = format!(
529 "[{}]",
530 shell
531 .iter()
532 .map(|a| format!("\"{}\"", escape_json_string(a)))
533 .collect::<Vec<_>>()
534 .join(", ")
535 );
536 Self::new("config")
537 .arg("--shell")
538 .arg(json_array)
539 .arg(container)
540 }
541
542 pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
544 match healthcheck {
545 HealthcheckInstruction::None => Self::new("config")
546 .arg("--healthcheck")
547 .arg("NONE")
548 .arg(container),
549 HealthcheckInstruction::Check {
550 command,
551 interval,
552 timeout,
553 start_period,
554 retries,
555 ..
556 } => {
557 let mut cmd = Self::new("config");
558
559 let cmd_str = match command {
560 ShellOrExec::Shell(s) => format!("CMD {}", s),
561 ShellOrExec::Exec(args) => {
562 format!(
563 "CMD [{}]",
564 args.iter()
565 .map(|a| format!("\"{}\"", escape_json_string(a)))
566 .collect::<Vec<_>>()
567 .join(", ")
568 )
569 }
570 };
571
572 cmd = cmd.arg("--healthcheck").arg(cmd_str);
573
574 if let Some(i) = interval {
575 cmd = cmd
576 .arg("--healthcheck-interval")
577 .arg(format!("{}s", i.as_secs()));
578 }
579
580 if let Some(t) = timeout {
581 cmd = cmd
582 .arg("--healthcheck-timeout")
583 .arg(format!("{}s", t.as_secs()));
584 }
585
586 if let Some(sp) = start_period {
587 cmd = cmd
588 .arg("--healthcheck-start-period")
589 .arg(format!("{}s", sp.as_secs()));
590 }
591
592 if let Some(r) = retries {
593 cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
594 }
595
596 cmd.arg(container)
597 }
598 }
599 }
600
601 pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
609 match instruction {
610 Instruction::Run(run) => {
611 if run.mounts.is_empty() {
613 vec![Self::run(container, &run.command)]
614 } else {
615 vec![Self::run_with_mounts(container, run)]
616 }
617 }
618
619 Instruction::Copy(copy) => {
620 vec![Self::copy_instruction(container, copy)]
621 }
622
623 Instruction::Add(add) => {
624 vec![Self::add_instruction(container, add)]
625 }
626
627 Instruction::Env(env) => Self::config_envs(container, env),
628
629 Instruction::Workdir(dir) => {
630 vec![Self::config_workdir(container, dir)]
631 }
632
633 Instruction::Expose(expose) => {
634 vec![Self::config_expose(container, expose)]
635 }
636
637 Instruction::Label(labels) => Self::config_labels(container, labels),
638
639 Instruction::User(user) => {
640 vec![Self::config_user(container, user)]
641 }
642
643 Instruction::Entrypoint(cmd) => {
644 vec![Self::config_entrypoint(container, cmd)]
645 }
646
647 Instruction::Cmd(cmd) => {
648 vec![Self::config_cmd(container, cmd)]
649 }
650
651 Instruction::Volume(paths) => paths
652 .iter()
653 .map(|p| Self::config_volume(container, p))
654 .collect(),
655
656 Instruction::Shell(shell) => {
657 vec![Self::config_shell(container, shell)]
658 }
659
660 Instruction::Arg(_) => {
661 vec![]
663 }
664
665 Instruction::Stopsignal(signal) => {
666 vec![Self::config_stopsignal(container, signal)]
667 }
668
669 Instruction::Healthcheck(hc) => {
670 vec![Self::config_healthcheck(container, hc)]
671 }
672
673 Instruction::Onbuild(_) => {
674 tracing::warn!("ONBUILD instruction not supported in buildah conversion");
676 vec![]
677 }
678 }
679 }
680}
681
682fn escape_json_string(s: &str) -> String {
684 s.replace('\\', "\\\\")
685 .replace('"', "\\\"")
686 .replace('\n', "\\n")
687 .replace('\r', "\\r")
688 .replace('\t', "\\t")
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use crate::dockerfile::RunInstruction;
695
696 #[test]
697 fn test_from_image() {
698 let cmd = BuildahCommand::from_image("alpine:3.18");
699 assert_eq!(cmd.program, "buildah");
700 assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
701 }
702
703 #[test]
704 fn test_run_shell() {
705 let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
706 assert_eq!(
707 cmd.args,
708 vec![
709 "run",
710 "container-1",
711 "--",
712 "/bin/sh",
713 "-c",
714 "apt-get update"
715 ]
716 );
717 }
718
719 #[test]
720 fn test_run_exec() {
721 let args = vec!["echo".to_string(), "hello".to_string()];
722 let cmd = BuildahCommand::run_exec("container-1", &args);
723 assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
724 }
725
726 #[test]
727 fn test_copy() {
728 let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
729 let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
730 assert_eq!(
731 cmd.args,
732 vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
733 );
734 }
735
736 #[test]
737 fn test_copy_from() {
738 let sources = vec!["/app".to_string()];
739 let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
740 assert_eq!(
741 cmd.args,
742 vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
743 );
744 }
745
746 #[test]
747 fn test_config_env() {
748 let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
749 assert_eq!(
750 cmd.args,
751 vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
752 );
753 }
754
755 #[test]
756 fn test_config_workdir() {
757 let cmd = BuildahCommand::config_workdir("container-1", "/app");
758 assert_eq!(
759 cmd.args,
760 vec!["config", "--workingdir", "/app", "container-1"]
761 );
762 }
763
764 #[test]
765 fn test_config_entrypoint_exec() {
766 let args = vec!["/app".to_string(), "--config".to_string()];
767 let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
768 assert!(cmd.args.contains(&"--entrypoint".to_string()));
769 assert!(cmd
770 .args
771 .iter()
772 .any(|a| a.contains("[") && a.contains("/app")));
773 }
774
775 #[test]
776 fn test_commit() {
777 let cmd = BuildahCommand::commit("container-1", "myimage:latest");
778 assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
779 }
780
781 #[test]
782 fn test_to_command_string() {
783 let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
784 let s = cmd.to_command_string();
785 assert!(s.starts_with("buildah config"));
786 assert!(s.contains("VAR=value with spaces"));
787 }
788
789 #[test]
790 fn test_from_instruction_run() {
791 let instruction = Instruction::Run(RunInstruction {
792 command: ShellOrExec::Shell("echo hello".to_string()),
793 mounts: vec![],
794 network: None,
795 security: None,
796 });
797
798 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
799 assert_eq!(cmds.len(), 1);
800 assert!(cmds[0].args.contains(&"run".to_string()));
801 }
802
803 #[test]
804 fn test_from_instruction_env_multiple() {
805 let mut vars = HashMap::new();
806 vars.insert("FOO".to_string(), "bar".to_string());
807 vars.insert("BAZ".to_string(), "qux".to_string());
808
809 let instruction = Instruction::Env(EnvInstruction { vars });
810 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
811
812 assert_eq!(cmds.len(), 2);
814 for cmd in &cmds {
815 assert!(cmd.args.contains(&"config".to_string()));
816 assert!(cmd.args.contains(&"--env".to_string()));
817 }
818 }
819
820 #[test]
821 fn test_escape_json_string() {
822 assert_eq!(escape_json_string("hello"), "hello");
823 assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
824 assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
825 }
826
827 #[test]
828 fn test_run_with_mounts_cache() {
829 use crate::dockerfile::{CacheSharing, RunMount};
830
831 let run = RunInstruction {
832 command: ShellOrExec::Shell("apt-get update".to_string()),
833 mounts: vec![RunMount::Cache {
834 target: "/var/cache/apt".to_string(),
835 id: Some("apt-cache".to_string()),
836 sharing: CacheSharing::Shared,
837 readonly: false,
838 }],
839 network: None,
840 security: None,
841 };
842
843 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
844
845 let mount_idx = cmd
847 .args
848 .iter()
849 .position(|a| a.starts_with("--mount="))
850 .expect("should have --mount arg");
851 let container_idx = cmd
852 .args
853 .iter()
854 .position(|a| a == "container-1")
855 .expect("should have container id");
856
857 assert!(
858 mount_idx < container_idx,
859 "--mount should come before container ID"
860 );
861
862 assert!(cmd.args[mount_idx].contains("type=cache"));
864 assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
865 assert!(cmd.args[mount_idx].contains("id=apt-cache"));
866 assert!(cmd.args[mount_idx].contains("sharing=shared"));
867 }
868
869 #[test]
870 fn test_run_with_multiple_mounts() {
871 use crate::dockerfile::{CacheSharing, RunMount};
872
873 let run = RunInstruction {
874 command: ShellOrExec::Shell("cargo build".to_string()),
875 mounts: vec![
876 RunMount::Cache {
877 target: "/usr/local/cargo/registry".to_string(),
878 id: Some("cargo-registry".to_string()),
879 sharing: CacheSharing::Shared,
880 readonly: false,
881 },
882 RunMount::Cache {
883 target: "/app/target".to_string(),
884 id: Some("cargo-target".to_string()),
885 sharing: CacheSharing::Locked,
886 readonly: false,
887 },
888 ],
889 network: None,
890 security: None,
891 };
892
893 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
894
895 let mount_count = cmd
897 .args
898 .iter()
899 .filter(|a| a.starts_with("--mount="))
900 .count();
901 assert_eq!(mount_count, 2, "should have 2 mount arguments");
902
903 let container_idx = cmd
905 .args
906 .iter()
907 .position(|a| a == "container-1")
908 .expect("should have container id");
909
910 for (idx, arg) in cmd.args.iter().enumerate() {
911 if arg.starts_with("--mount=") {
912 assert!(
913 idx < container_idx,
914 "--mount at index {} should come before container ID at {}",
915 idx,
916 container_idx
917 );
918 }
919 }
920 }
921
922 #[test]
923 fn test_from_instruction_run_with_mounts() {
924 use crate::dockerfile::{CacheSharing, RunMount};
925
926 let instruction = Instruction::Run(RunInstruction {
927 command: ShellOrExec::Shell("npm install".to_string()),
928 mounts: vec![RunMount::Cache {
929 target: "/root/.npm".to_string(),
930 id: Some("npm-cache".to_string()),
931 sharing: CacheSharing::Shared,
932 readonly: false,
933 }],
934 network: None,
935 security: None,
936 });
937
938 let cmds = BuildahCommand::from_instruction("container-1", &instruction);
939 assert_eq!(cmds.len(), 1);
940
941 let cmd = &cmds[0];
942 assert!(
943 cmd.args.iter().any(|a| a.starts_with("--mount=")),
944 "should include --mount argument"
945 );
946 }
947
948 #[test]
949 fn test_run_with_mounts_exec_form() {
950 use crate::dockerfile::{CacheSharing, RunMount};
951
952 let run = RunInstruction {
953 command: ShellOrExec::Exec(vec![
954 "pip".to_string(),
955 "install".to_string(),
956 "-r".to_string(),
957 "requirements.txt".to_string(),
958 ]),
959 mounts: vec![RunMount::Cache {
960 target: "/root/.cache/pip".to_string(),
961 id: Some("pip-cache".to_string()),
962 sharing: CacheSharing::Shared,
963 readonly: false,
964 }],
965 network: None,
966 security: None,
967 };
968
969 let cmd = BuildahCommand::run_with_mounts("container-1", &run);
970
971 assert!(cmd.args.contains(&"--".to_string()));
973 assert!(cmd.args.contains(&"pip".to_string()));
974 assert!(cmd.args.contains(&"install".to_string()));
975 }
976}