use std::collections::HashMap;
use std::fmt::Write as _;
use super::instruction::{
AddInstruction, CopyInstruction, EnvInstruction, ExposeProtocol, HealthcheckInstruction,
Instruction, RunInstruction, RunMount, RunNetwork, RunSecurity, ShellOrExec,
};
use super::parser::{Dockerfile, Stage};
use super::variable::expand_variables;
#[must_use]
pub(crate) fn escape_json_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn shell_quote(s: &str) -> String {
if s.is_empty() {
return "\"\"".to_string();
}
let needs_quote = s.chars().any(|c| {
c.is_whitespace()
|| matches!(
c,
'"' | '\''
| '\\'
| '$'
| '`'
| '&'
| '|'
| ';'
| '<'
| '>'
| '('
| ')'
| '*'
| '?'
| '['
| ']'
| '{'
| '}'
| '#'
| '~'
| '!'
)
});
if !needs_quote {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' | '\\' | '$' | '`' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out.push('"');
out
}
fn render_json_array(args: &[String]) -> String {
let inner: Vec<String> = args
.iter()
.map(|a| format!("\"{}\"", escape_json_string(a)))
.collect();
format!("[{}]", inner.join(", "))
}
fn render_inline_env(env: &HashMap<String, String>) -> String {
let mut keys: Vec<&String> = env.keys().collect();
keys.sort();
keys.iter()
.map(|k| format!("{}={}", k, shell_quote(&env[*k])))
.collect::<Vec<_>>()
.join(" ")
}
pub fn merge_default_cache_mounts(df: &mut Dockerfile, options: &crate::builder::BuildOptions) {
if options.default_cache_mounts.is_empty() {
return;
}
for stage in &mut df.stages {
for instruction in &mut stage.instructions {
let Instruction::Run(run) = instruction else {
continue;
};
for default_mount in &options.default_cache_mounts {
let RunMount::Cache { target, .. } = default_mount else {
continue;
};
let already_has = run
.mounts
.iter()
.any(|m| matches!(m, RunMount::Cache { target: t, .. } if t == target));
if !already_has {
run.mounts.push(default_mount.clone());
}
}
}
}
}
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn expand_dockerfile(df: &Dockerfile, build_args: &HashMap<String, String>) -> Dockerfile {
let mut out = df.clone();
for stage in &mut out.stages {
let mut arg_values: HashMap<String, String> = build_args.clone();
for global_arg in &df.global_args {
if !arg_values.contains_key(&global_arg.name) {
if let Some(default) = &global_arg.default {
arg_values.insert(global_arg.name.clone(), default.clone());
}
}
}
let mut env_values: HashMap<String, String> = HashMap::new();
for instruction in &mut stage.instructions {
*instruction = expand_instruction(instruction, &mut arg_values, &mut env_values);
}
}
out
}
fn declared_arg_names(df: &Dockerfile) -> Vec<String> {
let mut names: Vec<String> = df.global_args.iter().map(|a| a.name.clone()).collect();
for stage in &df.stages {
for instruction in &stage.instructions {
if let Instruction::Arg(arg) = instruction {
names.push(arg.name.clone());
}
}
}
names
}
#[allow(clippy::implicit_hasher)]
pub fn forward_build_arg_env(
df: &Dockerfile,
args: &mut std::collections::BTreeMap<String, String>,
) {
for name in declared_arg_names(df) {
if args.get(&name).is_some_and(|v| !v.is_empty()) {
continue;
}
match std::env::var(&name) {
Ok(value) if !value.is_empty() => {
args.insert(name, value);
}
_ => {}
}
}
}
fn expand_instruction(
instruction: &Instruction,
arg_values: &mut HashMap<String, String>,
env_values: &mut HashMap<String, String>,
) -> Instruction {
match instruction {
Instruction::Run(run) => {
let mut run = run.clone();
run.command = match &run.command {
ShellOrExec::Shell(s) => {
ShellOrExec::Shell(expand_variables(s, arg_values, env_values))
}
ShellOrExec::Exec(args) => ShellOrExec::Exec(
args.iter()
.map(|a| expand_variables(a, arg_values, env_values))
.collect(),
),
};
run.env = run
.env
.iter()
.map(|(k, v)| (k.clone(), expand_variables(v, arg_values, env_values)))
.collect();
Instruction::Run(run)
}
Instruction::Env(env) => {
let mut vars = HashMap::with_capacity(env.vars.len());
for (key, value) in &env.vars {
let expanded = expand_variables(value, arg_values, env_values);
env_values.insert(key.clone(), expanded.clone());
vars.insert(key.clone(), expanded);
}
Instruction::Env(EnvInstruction { vars })
}
Instruction::Copy(copy) => {
let mut copy = copy.clone();
copy.sources = copy
.sources
.iter()
.map(|s| expand_variables(s, arg_values, env_values))
.collect();
copy.destination = expand_variables(©.destination, arg_values, env_values);
Instruction::Copy(copy)
}
Instruction::Add(add) => {
let mut add = add.clone();
add.sources = add
.sources
.iter()
.map(|s| expand_variables(s, arg_values, env_values))
.collect();
add.destination = expand_variables(&add.destination, arg_values, env_values);
Instruction::Add(add)
}
Instruction::Workdir(dir) => {
Instruction::Workdir(expand_variables(dir, arg_values, env_values))
}
Instruction::User(user) => {
Instruction::User(expand_variables(user, arg_values, env_values))
}
Instruction::Label(labels) => {
let expanded = labels
.iter()
.map(|(k, v)| (k.clone(), expand_variables(v, arg_values, env_values)))
.collect();
Instruction::Label(expanded)
}
Instruction::Arg(arg) => {
if !arg_values.contains_key(&arg.name) {
if let Some(default) = &arg.default {
let expanded = expand_variables(default, arg_values, env_values);
arg_values.insert(arg.name.clone(), expanded);
}
}
instruction.clone()
}
other => other.clone(),
}
}
#[must_use]
pub fn render_dockerfile(df: &Dockerfile) -> String {
let mut out = String::new();
for arg in &df.global_args {
match &arg.default {
Some(default) => {
let _ = writeln!(out, "ARG {}={}", arg.name, default);
}
None => {
let _ = writeln!(out, "ARG {}", arg.name);
}
}
}
for (idx, stage) in df.stages.iter().enumerate() {
if idx > 0 || !df.global_args.is_empty() {
out.push('\n');
}
render_stage(stage, &mut out);
}
out
}
fn render_stage(stage: &Stage, out: &mut String) {
out.push_str("FROM ");
if let Some(platform) = &stage.platform {
let _ = write!(out, "--platform={platform} ");
}
out.push_str(&stage.base_image.to_string());
if let Some(name) = &stage.name {
let _ = write!(out, " AS {name}");
}
out.push('\n');
for instruction in &stage.instructions {
out.push_str(&render_instruction(instruction));
out.push('\n');
}
}
#[allow(clippy::too_many_lines)]
fn render_instruction(instruction: &Instruction) -> String {
match instruction {
Instruction::Run(run) => render_run(run),
Instruction::Copy(copy) => render_copy(copy),
Instruction::Add(add) => render_add(add),
Instruction::Env(env) => {
let mut keys: Vec<&String> = env.vars.keys().collect();
keys.sort();
let parts: Vec<String> = keys
.iter()
.map(|k| format!("{}={}", k, shell_quote(&env.vars[*k])))
.collect();
format!("ENV {}", parts.join(" "))
}
Instruction::Workdir(dir) => format!("WORKDIR {dir}"),
Instruction::Expose(expose) => {
let proto = match expose.protocol {
ExposeProtocol::Tcp => "tcp",
ExposeProtocol::Udp => "udp",
};
format!("EXPOSE {}/{proto}", expose.port)
}
Instruction::Label(labels) => {
let mut keys: Vec<&String> = labels.keys().collect();
keys.sort();
let parts: Vec<String> = keys
.iter()
.map(|k| format!("{}=\"{}\"", k, escape_json_string(&labels[*k])))
.collect();
format!("LABEL {}", parts.join(" "))
}
Instruction::User(user) => format!("USER {user}"),
Instruction::Entrypoint(cmd) => render_shell_or_exec("ENTRYPOINT", cmd),
Instruction::Cmd(cmd) => render_shell_or_exec("CMD", cmd),
Instruction::Volume(paths) => format!("VOLUME {}", render_json_array(paths)),
Instruction::Shell(shell) => format!("SHELL {}", render_json_array(shell)),
Instruction::Arg(arg) => match &arg.default {
Some(default) => format!("ARG {}={}", arg.name, default),
None => format!("ARG {}", arg.name),
},
Instruction::Stopsignal(signal) => format!("STOPSIGNAL {signal}"),
Instruction::Healthcheck(health) => render_healthcheck(health),
Instruction::Onbuild(inner) => format!("ONBUILD {}", render_instruction(inner)),
}
}
fn render_shell_or_exec(keyword: &str, cmd: &ShellOrExec) -> String {
match cmd {
ShellOrExec::Exec(args) => format!("{keyword} {}", render_json_array(args)),
ShellOrExec::Shell(s) => format!("{keyword} {s}"),
}
}
fn render_run(run: &RunInstruction) -> String {
let mut flags = String::new();
for mount in &run.mounts {
let _ = write!(flags, "--mount={} ", mount.to_buildah_arg());
}
if let Some(network) = &run.network {
let net = match network {
RunNetwork::Default => "default",
RunNetwork::None => "none",
RunNetwork::Host => "host",
};
let _ = write!(flags, "--network={net} ");
}
if let Some(security) = &run.security {
let sec = match security {
RunSecurity::Sandbox => "sandbox",
RunSecurity::Insecure => "insecure",
};
let _ = write!(flags, "--security={sec} ");
}
let env_prefix = if run.env.is_empty() {
String::new()
} else {
format!("{} ", render_inline_env(&run.env))
};
match &run.command {
ShellOrExec::Shell(s) => {
format!("RUN {flags}{env_prefix}{s}")
}
ShellOrExec::Exec(args) => {
if run.env.is_empty() {
format!("RUN {flags}{}", render_json_array(args))
} else {
let joined = args
.iter()
.map(|a| shell_quote(a))
.collect::<Vec<_>>()
.join(" ");
format!("RUN {flags}{env_prefix}{joined}")
}
}
}
}
fn render_copy(copy: &CopyInstruction) -> String {
let mut s = String::from("COPY ");
if let Some(from) = ©.from {
let _ = write!(s, "--from={from} ");
}
if let Some(chown) = ©.chown {
let _ = write!(s, "--chown={chown} ");
}
if let Some(chmod) = ©.chmod {
let _ = write!(s, "--chmod={chmod} ");
}
if copy.link {
s.push_str("--link ");
}
for exclude in ©.exclude {
let _ = write!(s, "--exclude={exclude} ");
}
s.push_str(©.sources.join(" "));
s.push(' ');
s.push_str(©.destination);
s
}
fn render_add(add: &AddInstruction) -> String {
let mut s = String::from("ADD ");
if let Some(chown) = &add.chown {
let _ = write!(s, "--chown={chown} ");
}
if let Some(chmod) = &add.chmod {
let _ = write!(s, "--chmod={chmod} ");
}
if add.link {
s.push_str("--link ");
}
if let Some(checksum) = &add.checksum {
let _ = write!(s, "--checksum={checksum} ");
}
s.push_str(&add.sources.join(" "));
s.push(' ');
s.push_str(&add.destination);
s
}
fn render_healthcheck(health: &HealthcheckInstruction) -> String {
match health {
HealthcheckInstruction::None => "HEALTHCHECK NONE".to_string(),
HealthcheckInstruction::Check {
command,
interval,
timeout,
start_period,
start_interval,
retries,
} => {
let mut s = String::from("HEALTHCHECK");
if let Some(d) = interval {
let _ = write!(s, " --interval={}s", d.as_secs());
}
if let Some(d) = timeout {
let _ = write!(s, " --timeout={}s", d.as_secs());
}
if let Some(d) = start_period {
let _ = write!(s, " --start-period={}s", d.as_secs());
}
if let Some(d) = start_interval {
let _ = write!(s, " --start-interval={}s", d.as_secs());
}
if let Some(r) = retries {
let _ = write!(s, " --retries={r}");
}
match command {
ShellOrExec::Exec(args) => {
let _ = write!(s, " CMD {}", render_json_array(args));
}
ShellOrExec::Shell(cmd) => {
let _ = write!(s, " CMD {cmd}");
}
}
s
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dockerfile::{
AddInstruction, ArgInstruction, CacheSharing, CopyInstruction, DockerfileFromTarget,
EnvInstruction, ExposeInstruction, HealthcheckInstruction, Instruction, RunInstruction,
RunMount, ShellOrExec, Stage,
};
use std::str::FromStr;
use std::time::Duration;
use zlayer_types::ImageReference;
fn img(s: &str) -> DockerfileFromTarget {
DockerfileFromTarget::Image(ImageReference::from_str(s).unwrap())
}
fn normalize(inst: &Instruction) -> Instruction {
match inst {
Instruction::Workdir(s) => Instruction::Workdir(s.trim().to_string()),
Instruction::User(s) => Instruction::User(s.trim().to_string()),
Instruction::Stopsignal(s) => Instruction::Stopsignal(s.trim().to_string()),
other => other.clone(),
}
}
fn assert_instructions_eq(expected: &[Instruction], actual: &[Instruction]) {
assert_eq!(
expected.len(),
actual.len(),
"instruction count mismatch\nexpected: {expected:#?}\nactual: {actual:#?}"
);
for (e, a) in expected.iter().zip(actual.iter()) {
assert_eq!(normalize(e), normalize(a), "instruction mismatch");
}
}
#[test]
fn forward_build_arg_env_fills_only_declared_empty_args() {
use std::collections::BTreeMap;
let declared_empty = "ZLAYER_TEST_FWD_DECLARED_EMPTY";
let declared_explicit = "ZLAYER_TEST_FWD_DECLARED_EXPLICIT";
let declared_noenv = "ZLAYER_TEST_FWD_DECLARED_NOENV";
let undeclared = "ZLAYER_TEST_FWD_UNDECLARED";
std::env::set_var(declared_empty, "from-env");
std::env::set_var(declared_explicit, "from-env");
std::env::set_var(undeclared, "from-env");
std::env::remove_var(declared_noenv);
let df = Dockerfile {
global_args: vec![
ArgInstruction::new(declared_empty),
ArgInstruction::new(declared_noenv),
],
stages: vec![Stage {
index: 0,
name: None,
base_image: img("alpine:3.18"),
platform: None,
instructions: vec![Instruction::Arg(ArgInstruction::new(declared_explicit))],
}],
};
let mut args: BTreeMap<String, String> = BTreeMap::new();
args.insert(declared_empty.to_string(), String::new());
args.insert(declared_explicit.to_string(), "explicit".to_string());
forward_build_arg_env(&df, &mut args);
assert_eq!(
args.get(declared_empty).map(String::as_str),
Some("from-env")
);
assert_eq!(
args.get(declared_explicit).map(String::as_str),
Some("explicit")
);
assert!(!args.contains_key(declared_noenv));
assert!(!args.contains_key(undeclared));
std::env::remove_var(declared_empty);
std::env::remove_var(declared_explicit);
std::env::remove_var(undeclared);
}
#[test]
fn round_trip_multistage_representative() {
let stage0 = Stage {
index: 0,
name: Some("builder".to_string()),
base_image: img("golang:1.21"),
platform: None,
instructions: vec![
Instruction::Workdir("/src".to_string()),
Instruction::Copy(CopyInstruction::new(vec![".".to_string()], ".".to_string())),
Instruction::Run(RunInstruction::shell("go build -o /app")),
Instruction::Run(RunInstruction::exec(vec![
"/bin/true".to_string(),
"arg with space".to_string(),
])),
Instruction::Arg(ArgInstruction::with_default("VERSION", "1.0")),
Instruction::Env(EnvInstruction::new("FOO", "bar baz")),
Instruction::Expose(ExposeInstruction::tcp(8080)),
Instruction::Stopsignal("SIGTERM".to_string()),
],
};
let mut label_map = std::collections::HashMap::new();
label_map.insert(
"org.opencontainers.image.title".to_string(),
"my app".to_string(),
);
let stage1 = Stage {
index: 1,
name: None,
base_image: img("alpine:3.18"),
platform: None,
instructions: vec![
Instruction::Copy(
CopyInstruction::new(vec!["/app".to_string()], "/app".to_string())
.from_stage("builder")
.chown("1000:1000"),
),
Instruction::User("appuser".to_string()),
Instruction::Label(label_map),
Instruction::Volume(vec!["/data".to_string(), "/cache".to_string()]),
Instruction::Shell(vec!["/bin/sh".to_string(), "-c".to_string()]),
Instruction::Entrypoint(ShellOrExec::Exec(vec!["/app".to_string()])),
Instruction::Cmd(ShellOrExec::Shell("--serve".to_string())),
],
};
let df = Dockerfile {
global_args: vec![ArgInstruction::with_default("BASE_TAG", "latest")],
stages: vec![stage0, stage1],
};
let text = render_dockerfile(&df);
let reparsed = Dockerfile::parse(&text)
.unwrap_or_else(|e| panic!("re-parse failed: {e}\n---\n{text}\n---"));
assert_eq!(
reparsed.global_args.len(),
df.global_args.len(),
"global args mismatch\n{text}"
);
assert_eq!(
reparsed.stages.len(),
df.stages.len(),
"stage count\n{text}"
);
for (orig, re) in df.stages.iter().zip(reparsed.stages.iter()) {
assert_eq!(orig.name, re.name, "stage name\n{text}");
assert_eq!(
orig.base_image.to_string(),
re.base_image.to_string(),
"base image\n{text}"
);
assert_instructions_eq(&orig.instructions, &re.instructions);
}
}
#[test]
fn render_healthcheck_variants() {
assert_eq!(
render_instruction(&Instruction::Healthcheck(HealthcheckInstruction::None)),
"HEALTHCHECK NONE"
);
let hc = HealthcheckInstruction::Check {
command: ShellOrExec::Shell("curl -f http://localhost/ || exit 1".to_string()),
interval: Some(Duration::from_secs(30)),
timeout: Some(Duration::from_secs(5)),
start_period: Some(Duration::from_secs(10)),
start_interval: Some(Duration::from_secs(2)),
retries: Some(3),
};
assert_eq!(
render_instruction(&Instruction::Healthcheck(hc)),
"HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=2s \
--retries=3 CMD curl -f http://localhost/ || exit 1"
);
let hc_exec = HealthcheckInstruction::cmd(ShellOrExec::Exec(vec![
"curl".to_string(),
"-f".to_string(),
"http://localhost/".to_string(),
]));
assert_eq!(
render_instruction(&Instruction::Healthcheck(hc_exec)),
r#"HEALTHCHECK CMD ["curl", "-f", "http://localhost/"]"#
);
}
#[test]
fn render_run_with_cache_mount() {
let mut run = RunInstruction::shell("apt-get update");
run.mounts = vec![RunMount::Cache {
target: "/var/cache/apt".to_string(),
id: Some("apt".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}];
let line = render_instruction(&Instruction::Run(run));
assert_eq!(
line,
"RUN --mount=type=cache,target=/var/cache/apt,id=apt,sharing=shared apt-get update"
);
}
#[test]
fn round_trip_run_exec_no_env() {
let df = Dockerfile {
global_args: vec![],
stages: vec![Stage {
index: 0,
name: None,
base_image: img("alpine"),
platform: None,
instructions: vec![Instruction::Run(RunInstruction::exec(vec![
"echo".to_string(),
"hello world".to_string(),
]))],
}],
};
let text = render_dockerfile(&df);
assert!(
text.contains(r#"RUN ["echo", "hello world"]"#),
"got:\n{text}"
);
let reparsed = Dockerfile::parse(&text).unwrap();
assert_instructions_eq(&df.stages[0].instructions, &reparsed.stages[0].instructions);
}
#[test]
fn round_trip_run_with_per_step_env() {
let mut run = RunInstruction::shell("make build");
run.env.insert("CC".to_string(), "clang".to_string());
run.env.insert("FLAGS".to_string(), "-O2 -g".to_string());
let df = Dockerfile {
global_args: vec![],
stages: vec![Stage {
index: 0,
name: None,
base_image: img("alpine"),
platform: None,
instructions: vec![Instruction::Run(run)],
}],
};
let text = render_dockerfile(&df);
assert!(
text.contains(r#"RUN CC=clang FLAGS="-O2 -g" make build"#),
"got:\n{text}"
);
let reparsed = Dockerfile::parse(&text).unwrap();
assert_eq!(reparsed.stages[0].instructions.len(), 1);
assert!(matches!(
&reparsed.stages[0].instructions[0],
Instruction::Run(_)
));
}
#[test]
fn round_trip_onbuild() {
let df = Dockerfile {
global_args: vec![],
stages: vec![Stage {
index: 0,
name: None,
base_image: img("alpine"),
platform: None,
instructions: vec![Instruction::Onbuild(Box::new(Instruction::Run(
RunInstruction::shell("echo onbuild"),
)))],
}],
};
let text = render_dockerfile(&df);
assert!(text.contains("ONBUILD RUN echo onbuild"), "got:\n{text}");
}
#[test]
fn render_add_with_checksum() {
let mut add = AddInstruction::new(
vec!["https://example.com/file.tar.gz".to_string()],
"/opt/file.tar.gz".to_string(),
);
add.checksum = Some("sha256:abc".to_string());
add.chown = Some("0:0".to_string());
let line = render_instruction(&Instruction::Add(add));
assert_eq!(
line,
"ADD --chown=0:0 --checksum=sha256:abc https://example.com/file.tar.gz /opt/file.tar.gz"
);
}
#[test]
fn expand_dockerfile_bug3_run_env_and_command() {
let mut run = RunInstruction::shell("auth --token ${FORGEJO_TOKEN}");
run.env
.insert("TOKEN".to_string(), "${FORGEJO_TOKEN}".to_string());
let df = Dockerfile {
global_args: vec![],
stages: vec![Stage {
index: 0,
name: None,
base_image: img("alpine"),
platform: None,
instructions: vec![Instruction::Run(run)],
}],
};
let build_args: std::collections::HashMap<String, String> =
[("FORGEJO_TOKEN".to_string(), "s3cr3t".to_string())]
.into_iter()
.collect();
let expanded = expand_dockerfile(&df, &build_args);
let Instruction::Run(run) = &expanded.stages[0].instructions[0] else {
panic!("expected RUN");
};
assert_eq!(
run.env.get("TOKEN"),
Some(&"s3cr3t".to_string()),
"BUG3: run.env value must be substituted"
);
match &run.command {
ShellOrExec::Shell(s) => assert_eq!(s, "auth --token s3cr3t"),
ShellOrExec::Exec(args) => panic!("expected shell command, got exec: {args:?}"),
}
}
#[test]
fn merge_default_cache_mounts_adds_and_dedups() {
use crate::builder::BuildOptions;
use crate::dockerfile::{CacheSharing, RunInstruction, RunMount};
let run_bare = RunInstruction::shell("apt-get update");
let mut run_has_same = RunInstruction::shell("pip install foo");
run_has_same.mounts.push(RunMount::Cache {
target: "/var/cache/apt".to_string(),
id: Some("step".to_string()),
sharing: CacheSharing::Locked,
readonly: false,
});
let df = Dockerfile {
global_args: vec![],
stages: vec![Stage {
index: 0,
name: None,
base_image: img("alpine"),
platform: None,
instructions: vec![
Instruction::Run(run_bare),
Instruction::Run(run_has_same),
Instruction::Workdir("/app".to_string()),
],
}],
};
let mut ir = df;
let options = BuildOptions {
default_cache_mounts: vec![RunMount::Cache {
target: "/var/cache/apt".to_string(),
id: Some("auto".to_string()),
sharing: CacheSharing::Shared,
readonly: false,
}],
..BuildOptions::default()
};
super::merge_default_cache_mounts(&mut ir, &options);
let Instruction::Run(r0) = &ir.stages[0].instructions[0] else {
panic!("expected RUN");
};
assert_eq!(r0.mounts.len(), 1, "bare RUN gains the default cache mount");
assert!(matches!(
&r0.mounts[0],
RunMount::Cache { target, id, .. }
if target == "/var/cache/apt" && id.as_deref() == Some("auto")
));
let Instruction::Run(r1) = &ir.stages[0].instructions[1] else {
panic!("expected RUN");
};
assert_eq!(
r1.mounts.len(),
1,
"RUN with same-target cache mount is NOT duplicated"
);
assert!(matches!(
&r1.mounts[0],
RunMount::Cache { id, .. } if id.as_deref() == Some("step")
));
assert!(matches!(
&ir.stages[0].instructions[2],
Instruction::Workdir(_)
));
}
#[test]
fn merge_default_cache_mounts_noop_when_empty() {
use crate::builder::BuildOptions;
use crate::dockerfile::RunInstruction;
let df = Dockerfile {
global_args: vec![],
stages: vec![Stage {
index: 0,
name: None,
base_image: img("alpine"),
platform: None,
instructions: vec![Instruction::Run(RunInstruction::shell("echo hi"))],
}],
};
let mut ir = df;
super::merge_default_cache_mounts(&mut ir, &BuildOptions::default());
let Instruction::Run(r) = &ir.stages[0].instructions[0] else {
panic!("expected RUN");
};
assert!(r.mounts.is_empty());
}
#[test]
fn expand_dockerfile_arg_and_env_accumulation() {
let df = Dockerfile {
global_args: vec![],
stages: vec![Stage {
index: 0,
name: None,
base_image: img("alpine"),
platform: None,
instructions: vec![
Instruction::Arg(ArgInstruction::with_default("GREETING", "hi")),
Instruction::Env(EnvInstruction::new("WHO", "${GREETING} world")),
Instruction::Run(RunInstruction::shell("echo ${WHO}")),
],
}],
};
let expanded = expand_dockerfile(&df, &std::collections::HashMap::new());
let Instruction::Env(env) = &expanded.stages[0].instructions[1] else {
panic!("expected ENV");
};
assert_eq!(env.vars.get("WHO"), Some(&"hi world".to_string()));
let Instruction::Run(run) = &expanded.stages[0].instructions[2] else {
panic!("expected RUN");
};
match &run.command {
ShellOrExec::Shell(s) => assert_eq!(s, "echo hi world"),
ShellOrExec::Exec(args) => panic!("expected shell, got exec: {args:?}"),
}
}
}