mod executor;
mod install;
pub use executor::*;
pub use install::{
current_platform, install_instructions, is_platform_supported, BuildahInstallation,
BuildahInstaller, InstallError,
};
use crate::dockerfile::{
AddInstruction, CopyInstruction, EnvInstruction, ExposeInstruction, HealthcheckInstruction,
Instruction, RunInstruction, ShellOrExec,
};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct BuildahCommand {
pub program: String,
pub args: Vec<String>,
pub env: HashMap<String, String>,
}
impl BuildahCommand {
#[must_use]
pub fn new(subcommand: &str) -> Self {
Self {
program: "buildah".to_string(),
args: vec![subcommand.to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
#[must_use]
pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use]
pub fn arg_opt(self, flag: &str, value: Option<impl Into<String>>) -> Self {
if let Some(v) = value {
self.arg(flag).arg(v)
} else {
self
}
}
#[must_use]
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
#[must_use]
pub fn to_command_string(&self) -> String {
let mut parts = vec![self.program.clone()];
parts.extend(self.args.iter().map(|a| {
if a.contains(' ') || a.contains('"') {
format!("\"{}\"", a.replace('"', "\\\""))
} else {
a.clone()
}
}));
parts.join(" ")
}
#[must_use]
pub fn from_image(image: &str) -> Self {
Self::new("from").arg(image)
}
#[must_use]
pub fn from_image_named(image: &str, name: &str) -> Self {
Self::new("from").arg("--name").arg(name).arg(image)
}
#[must_use]
pub fn from_scratch() -> Self {
Self::new("from").arg("scratch")
}
#[must_use]
pub fn rm(container: &str) -> Self {
Self::new("rm").arg(container)
}
#[must_use]
pub fn commit(container: &str, image_name: &str) -> Self {
Self::new("commit").arg(container).arg(image_name)
}
#[must_use]
pub fn commit_with_opts(
container: &str,
image_name: &str,
format: Option<&str>,
squash: bool,
) -> Self {
let mut cmd = Self::new("commit");
if let Some(fmt) = format {
cmd = cmd.arg("--format").arg(fmt);
}
if squash {
cmd = cmd.arg("--squash");
}
cmd.arg(container).arg(image_name)
}
#[must_use]
pub fn tag(image: &str, new_name: &str) -> Self {
Self::new("tag").arg(image).arg(new_name)
}
#[must_use]
pub fn rmi(image: &str) -> Self {
Self::new("rmi").arg(image)
}
#[must_use]
pub fn push(image: &str) -> Self {
Self::new("push").arg(image)
}
#[must_use]
pub fn push_to(image: &str, destination: &str) -> Self {
Self::new("push").arg(image).arg(destination)
}
#[must_use]
pub fn inspect(name: &str) -> Self {
Self::new("inspect").arg(name)
}
#[must_use]
pub fn inspect_format(name: &str, format: &str) -> Self {
Self::new("inspect").arg("--format").arg(format).arg(name)
}
#[must_use]
pub fn images() -> Self {
Self::new("images")
}
#[must_use]
pub fn containers() -> Self {
Self::new("containers")
}
#[must_use]
pub fn run_shell(container: &str, command: &str) -> Self {
Self::new("run")
.arg(container)
.arg("--")
.arg("/bin/sh")
.arg("-c")
.arg(command)
}
#[must_use]
pub fn run_exec(container: &str, args: &[String]) -> Self {
let mut cmd = Self::new("run").arg(container).arg("--");
for arg in args {
cmd = cmd.arg(arg);
}
cmd
}
#[must_use]
pub fn run(container: &str, command: &ShellOrExec) -> Self {
match command {
ShellOrExec::Shell(s) => Self::run_shell(container, s),
ShellOrExec::Exec(args) => Self::run_exec(container, args),
}
}
#[must_use]
pub fn run_with_mounts(container: &str, run: &RunInstruction) -> Self {
let mut cmd = Self::new("run");
for mount in &run.mounts {
cmd = cmd.arg(format!("--mount={}", mount.to_buildah_arg()));
}
cmd = cmd.arg(container).arg("--");
match &run.command {
ShellOrExec::Shell(s) => cmd.arg("/bin/sh").arg("-c").arg(s),
ShellOrExec::Exec(args) => {
for arg in args {
cmd = cmd.arg(arg);
}
cmd
}
}
}
#[must_use]
pub fn copy(container: &str, sources: &[String], dest: &str) -> Self {
let mut cmd = Self::new("copy").arg(container);
for src in sources {
cmd = cmd.arg(src);
}
cmd.arg(dest)
}
#[must_use]
pub fn copy_from(container: &str, from: &str, sources: &[String], dest: &str) -> Self {
let mut cmd = Self::new("copy").arg("--from").arg(from).arg(container);
for src in sources {
cmd = cmd.arg(src);
}
cmd.arg(dest)
}
#[must_use]
pub fn copy_instruction(container: &str, copy: &CopyInstruction) -> Self {
let mut cmd = Self::new("copy");
if let Some(ref from) = copy.from {
cmd = cmd.arg("--from").arg(from);
}
if let Some(ref chown) = copy.chown {
cmd = cmd.arg("--chown").arg(chown);
}
if let Some(ref chmod) = copy.chmod {
cmd = cmd.arg("--chmod").arg(chmod);
}
cmd = cmd.arg(container);
for src in ©.sources {
cmd = cmd.arg(src);
}
cmd.arg(©.destination)
}
#[must_use]
pub fn add(container: &str, sources: &[String], dest: &str) -> Self {
let mut cmd = Self::new("add").arg(container);
for src in sources {
cmd = cmd.arg(src);
}
cmd.arg(dest)
}
#[must_use]
pub fn add_instruction(container: &str, add: &AddInstruction) -> Self {
let mut cmd = Self::new("add");
if let Some(ref chown) = add.chown {
cmd = cmd.arg("--chown").arg(chown);
}
if let Some(ref chmod) = add.chmod {
cmd = cmd.arg("--chmod").arg(chmod);
}
cmd = cmd.arg(container);
for src in &add.sources {
cmd = cmd.arg(src);
}
cmd.arg(&add.destination)
}
#[must_use]
pub fn config_env(container: &str, key: &str, value: &str) -> Self {
Self::new("config")
.arg("--env")
.arg(format!("{key}={value}"))
.arg(container)
}
#[must_use]
pub fn config_envs(container: &str, env: &EnvInstruction) -> Vec<Self> {
env.vars
.iter()
.map(|(k, v)| Self::config_env(container, k, v))
.collect()
}
#[must_use]
pub fn config_workdir(container: &str, dir: &str) -> Self {
Self::new("config")
.arg("--workingdir")
.arg(dir)
.arg(container)
}
#[must_use]
pub fn config_expose(container: &str, expose: &ExposeInstruction) -> Self {
let port_spec = format!(
"{}/{}",
expose.port,
match expose.protocol {
crate::dockerfile::ExposeProtocol::Tcp => "tcp",
crate::dockerfile::ExposeProtocol::Udp => "udp",
}
);
Self::new("config")
.arg("--port")
.arg(port_spec)
.arg(container)
}
#[must_use]
pub fn config_entrypoint_shell(container: &str, command: &str) -> Self {
Self::new("config")
.arg("--entrypoint")
.arg(format!(
"[\"/bin/sh\", \"-c\", \"{}\"]",
escape_json_string(command)
))
.arg(container)
}
#[must_use]
pub fn config_entrypoint_exec(container: &str, args: &[String]) -> Self {
let json_array = format!(
"[{}]",
args.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect::<Vec<_>>()
.join(", ")
);
Self::new("config")
.arg("--entrypoint")
.arg(json_array)
.arg(container)
}
#[must_use]
pub fn config_entrypoint(container: &str, command: &ShellOrExec) -> Self {
match command {
ShellOrExec::Shell(s) => Self::config_entrypoint_shell(container, s),
ShellOrExec::Exec(args) => Self::config_entrypoint_exec(container, args),
}
}
#[must_use]
pub fn config_cmd_shell(container: &str, command: &str) -> Self {
Self::new("config")
.arg("--cmd")
.arg(format!("/bin/sh -c \"{}\"", escape_json_string(command)))
.arg(container)
}
#[must_use]
pub fn config_cmd_exec(container: &str, args: &[String]) -> Self {
let json_array = format!(
"[{}]",
args.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect::<Vec<_>>()
.join(", ")
);
Self::new("config")
.arg("--cmd")
.arg(json_array)
.arg(container)
}
#[must_use]
pub fn config_cmd(container: &str, command: &ShellOrExec) -> Self {
match command {
ShellOrExec::Shell(s) => Self::config_cmd_shell(container, s),
ShellOrExec::Exec(args) => Self::config_cmd_exec(container, args),
}
}
#[must_use]
pub fn config_user(container: &str, user: &str) -> Self {
Self::new("config").arg("--user").arg(user).arg(container)
}
#[must_use]
pub fn config_label(container: &str, key: &str, value: &str) -> Self {
Self::new("config")
.arg("--label")
.arg(format!("{key}={value}"))
.arg(container)
}
#[must_use]
pub fn config_labels(container: &str, labels: &HashMap<String, String>) -> Vec<Self> {
labels
.iter()
.map(|(k, v)| Self::config_label(container, k, v))
.collect()
}
#[must_use]
pub fn config_volume(container: &str, path: &str) -> Self {
Self::new("config").arg("--volume").arg(path).arg(container)
}
#[must_use]
pub fn config_stopsignal(container: &str, signal: &str) -> Self {
Self::new("config")
.arg("--stop-signal")
.arg(signal)
.arg(container)
}
#[must_use]
pub fn config_shell(container: &str, shell: &[String]) -> Self {
let json_array = format!(
"[{}]",
shell
.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect::<Vec<_>>()
.join(", ")
);
Self::new("config")
.arg("--shell")
.arg(json_array)
.arg(container)
}
#[must_use]
pub fn config_healthcheck(container: &str, healthcheck: &HealthcheckInstruction) -> Self {
match healthcheck {
HealthcheckInstruction::None => Self::new("config")
.arg("--healthcheck")
.arg("NONE")
.arg(container),
HealthcheckInstruction::Check {
command,
interval,
timeout,
start_period,
retries,
..
} => {
let mut cmd = Self::new("config");
let cmd_str = match command {
ShellOrExec::Shell(s) => format!("CMD {s}"),
ShellOrExec::Exec(args) => {
format!(
"CMD [{}]",
args.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect::<Vec<_>>()
.join(", ")
)
}
};
cmd = cmd.arg("--healthcheck").arg(cmd_str);
if let Some(i) = interval {
cmd = cmd
.arg("--healthcheck-interval")
.arg(format!("{}s", i.as_secs()));
}
if let Some(t) = timeout {
cmd = cmd
.arg("--healthcheck-timeout")
.arg(format!("{}s", t.as_secs()));
}
if let Some(sp) = start_period {
cmd = cmd
.arg("--healthcheck-start-period")
.arg(format!("{}s", sp.as_secs()));
}
if let Some(r) = retries {
cmd = cmd.arg("--healthcheck-retries").arg(r.to_string());
}
cmd.arg(container)
}
}
}
#[must_use]
pub fn manifest_create(name: &str) -> Self {
Self::new("manifest").arg("create").arg(name)
}
#[must_use]
pub fn manifest_add(list: &str, image: &str) -> Self {
Self::new("manifest").arg("add").arg(list).arg(image)
}
#[must_use]
pub fn manifest_push(list: &str, destination: &str) -> Self {
Self::new("manifest")
.arg("push")
.arg("--all")
.arg(list)
.arg(destination)
}
#[must_use]
pub fn manifest_rm(list: &str) -> Self {
Self::new("manifest").arg("rm").arg(list)
}
pub fn from_instruction(container: &str, instruction: &Instruction) -> Vec<Self> {
match instruction {
Instruction::Run(run) => {
if run.mounts.is_empty() {
vec![Self::run(container, &run.command)]
} else {
vec![Self::run_with_mounts(container, run)]
}
}
Instruction::Copy(copy) => {
vec![Self::copy_instruction(container, copy)]
}
Instruction::Add(add) => {
vec![Self::add_instruction(container, add)]
}
Instruction::Env(env) => Self::config_envs(container, env),
Instruction::Workdir(dir) => {
vec![Self::config_workdir(container, dir)]
}
Instruction::Expose(expose) => {
vec![Self::config_expose(container, expose)]
}
Instruction::Label(labels) => Self::config_labels(container, labels),
Instruction::User(user) => {
vec![Self::config_user(container, user)]
}
Instruction::Entrypoint(cmd) => {
vec![Self::config_entrypoint(container, cmd)]
}
Instruction::Cmd(cmd) => {
vec![Self::config_cmd(container, cmd)]
}
Instruction::Volume(paths) => paths
.iter()
.map(|p| Self::config_volume(container, p))
.collect(),
Instruction::Shell(shell) => {
vec![Self::config_shell(container, shell)]
}
Instruction::Arg(_) => {
vec![]
}
Instruction::Stopsignal(signal) => {
vec![Self::config_stopsignal(container, signal)]
}
Instruction::Healthcheck(hc) => {
vec![Self::config_healthcheck(container, hc)]
}
Instruction::Onbuild(_) => {
tracing::warn!("ONBUILD instruction not supported in buildah conversion");
vec![]
}
}
}
}
fn escape_json_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dockerfile::RunInstruction;
#[test]
fn test_from_image() {
let cmd = BuildahCommand::from_image("alpine:3.18");
assert_eq!(cmd.program, "buildah");
assert_eq!(cmd.args, vec!["from", "alpine:3.18"]);
}
#[test]
fn test_run_shell() {
let cmd = BuildahCommand::run_shell("container-1", "apt-get update");
assert_eq!(
cmd.args,
vec![
"run",
"container-1",
"--",
"/bin/sh",
"-c",
"apt-get update"
]
);
}
#[test]
fn test_run_exec() {
let args = vec!["echo".to_string(), "hello".to_string()];
let cmd = BuildahCommand::run_exec("container-1", &args);
assert_eq!(cmd.args, vec!["run", "container-1", "--", "echo", "hello"]);
}
#[test]
fn test_copy() {
let sources = vec!["src/".to_string(), "Cargo.toml".to_string()];
let cmd = BuildahCommand::copy("container-1", &sources, "/app/");
assert_eq!(
cmd.args,
vec!["copy", "container-1", "src/", "Cargo.toml", "/app/"]
);
}
#[test]
fn test_copy_from() {
let sources = vec!["/app".to_string()];
let cmd = BuildahCommand::copy_from("container-1", "builder", &sources, "/app");
assert_eq!(
cmd.args,
vec!["copy", "--from", "builder", "container-1", "/app", "/app"]
);
}
#[test]
fn test_config_env() {
let cmd = BuildahCommand::config_env("container-1", "PATH", "/usr/local/bin");
assert_eq!(
cmd.args,
vec!["config", "--env", "PATH=/usr/local/bin", "container-1"]
);
}
#[test]
fn test_config_workdir() {
let cmd = BuildahCommand::config_workdir("container-1", "/app");
assert_eq!(
cmd.args,
vec!["config", "--workingdir", "/app", "container-1"]
);
}
#[test]
fn test_config_entrypoint_exec() {
let args = vec!["/app".to_string(), "--config".to_string()];
let cmd = BuildahCommand::config_entrypoint_exec("container-1", &args);
assert!(cmd.args.contains(&"--entrypoint".to_string()));
assert!(cmd
.args
.iter()
.any(|a| a.contains('[') && a.contains("/app")));
}
#[test]
fn test_commit() {
let cmd = BuildahCommand::commit("container-1", "myimage:latest");
assert_eq!(cmd.args, vec!["commit", "container-1", "myimage:latest"]);
}
#[test]
fn test_to_command_string() {
let cmd = BuildahCommand::config_env("container-1", "VAR", "value with spaces");
let s = cmd.to_command_string();
assert!(s.starts_with("buildah config"));
assert!(s.contains("VAR=value with spaces"));
}
#[test]
fn test_from_instruction_run() {
let instruction = Instruction::Run(RunInstruction {
command: ShellOrExec::Shell("echo hello".to_string()),
mounts: vec![],
network: None,
security: None,
});
let cmds = BuildahCommand::from_instruction("container-1", &instruction);
assert_eq!(cmds.len(), 1);
assert!(cmds[0].args.contains(&"run".to_string()));
}
#[test]
fn test_from_instruction_env_multiple() {
let mut vars = HashMap::new();
vars.insert("FOO".to_string(), "bar".to_string());
vars.insert("BAZ".to_string(), "qux".to_string());
let instruction = Instruction::Env(EnvInstruction { vars });
let cmds = BuildahCommand::from_instruction("container-1", &instruction);
assert_eq!(cmds.len(), 2);
for cmd in &cmds {
assert!(cmd.args.contains(&"config".to_string()));
assert!(cmd.args.contains(&"--env".to_string()));
}
}
#[test]
fn test_escape_json_string() {
assert_eq!(escape_json_string("hello"), "hello");
assert_eq!(escape_json_string("hello \"world\""), "hello \\\"world\\\"");
assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
}
#[test]
fn test_run_with_mounts_cache() {
use crate::dockerfile::{CacheSharing, RunMount};
let run = RunInstruction {
command: ShellOrExec::Shell("apt-get update".to_string()),
mounts: vec![RunMount::Cache {
target: "/var/cache/apt".to_string(),
id: Some("apt-cache".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
network: None,
security: None,
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
let mount_idx = cmd
.args
.iter()
.position(|a| a.starts_with("--mount="))
.expect("should have --mount arg");
let container_idx = cmd
.args
.iter()
.position(|a| a == "container-1")
.expect("should have container id");
assert!(
mount_idx < container_idx,
"--mount should come before container ID"
);
assert!(cmd.args[mount_idx].contains("type=cache"));
assert!(cmd.args[mount_idx].contains("target=/var/cache/apt"));
assert!(cmd.args[mount_idx].contains("id=apt-cache"));
assert!(cmd.args[mount_idx].contains("sharing=shared"));
}
#[test]
fn test_run_with_multiple_mounts() {
use crate::dockerfile::{CacheSharing, RunMount};
let run = RunInstruction {
command: ShellOrExec::Shell("cargo build".to_string()),
mounts: vec![
RunMount::Cache {
target: "/usr/local/cargo/registry".to_string(),
id: Some("cargo-registry".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
},
RunMount::Cache {
target: "/app/target".to_string(),
id: Some("cargo-target".to_string()),
sharing: CacheSharing::Locked,
readonly: false,
},
],
network: None,
security: None,
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
let mount_count = cmd
.args
.iter()
.filter(|a| a.starts_with("--mount="))
.count();
assert_eq!(mount_count, 2, "should have 2 mount arguments");
let container_idx = cmd
.args
.iter()
.position(|a| a == "container-1")
.expect("should have container id");
for (idx, arg) in cmd.args.iter().enumerate() {
if arg.starts_with("--mount=") {
assert!(
idx < container_idx,
"--mount at index {idx} should come before container ID at {container_idx}",
);
}
}
}
#[test]
fn test_from_instruction_run_with_mounts() {
use crate::dockerfile::{CacheSharing, RunMount};
let instruction = Instruction::Run(RunInstruction {
command: ShellOrExec::Shell("npm install".to_string()),
mounts: vec![RunMount::Cache {
target: "/root/.npm".to_string(),
id: Some("npm-cache".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
network: None,
security: None,
});
let cmds = BuildahCommand::from_instruction("container-1", &instruction);
assert_eq!(cmds.len(), 1);
let cmd = &cmds[0];
assert!(
cmd.args.iter().any(|a| a.starts_with("--mount=")),
"should include --mount argument"
);
}
#[test]
fn test_run_with_mounts_exec_form() {
use crate::dockerfile::{CacheSharing, RunMount};
let run = RunInstruction {
command: ShellOrExec::Exec(vec![
"pip".to_string(),
"install".to_string(),
"-r".to_string(),
"requirements.txt".to_string(),
]),
mounts: vec![RunMount::Cache {
target: "/root/.cache/pip".to_string(),
id: Some("pip-cache".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
network: None,
security: None,
};
let cmd = BuildahCommand::run_with_mounts("container-1", &run);
assert!(cmd.args.contains(&"--".to_string()));
assert!(cmd.args.contains(&"pip".to_string()));
assert!(cmd.args.contains(&"install".to_string()));
}
#[test]
fn test_manifest_create() {
let cmd = BuildahCommand::manifest_create("myapp:latest");
assert_eq!(cmd.program, "buildah");
assert_eq!(cmd.args, vec!["manifest", "create", "myapp:latest"]);
}
#[test]
fn test_manifest_add() {
let cmd = BuildahCommand::manifest_add("myapp:latest", "myapp-amd64:latest");
assert_eq!(
cmd.args,
vec!["manifest", "add", "myapp:latest", "myapp-amd64:latest"]
);
}
#[test]
fn test_manifest_push() {
let cmd =
BuildahCommand::manifest_push("myapp:latest", "docker://registry.example.com/myapp");
assert_eq!(
cmd.args,
vec![
"manifest",
"push",
"--all",
"myapp:latest",
"docker://registry.example.com/myapp"
]
);
}
#[test]
fn test_manifest_rm() {
let cmd = BuildahCommand::manifest_rm("myapp:latest");
assert_eq!(cmd.args, vec!["manifest", "rm", "myapp:latest"]);
}
}