use crate::descriptor::Descriptor;
use crate::incremental::{mtime, Inputs, Stamp};
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
enum DockerfileSource {
UserProvided,
Generated,
}
fn dockerfile_source(project_root: &Path) -> DockerfileSource {
if project_root.join("Dockerfile").exists() {
DockerfileSource::UserProvided
} else {
DockerfileSource::Generated
}
}
pub fn docker_build(
project_root: &Path,
desc: &Descriptor,
jar: &Path,
dep_jars: &[PathBuf],
) -> Result<String> {
let image_ref = desc.image_ref();
match dockerfile_source(project_root) {
DockerfileSource::UserProvided => {
build_with_user_dockerfile(project_root, desc, jar, &image_ref)?;
}
DockerfileSource::Generated => {
build_with_generated_dockerfile(project_root, desc, jar, dep_jars, &image_ref)?;
}
}
Ok(image_ref)
}
pub fn docker_run(
project_root: &Path,
desc: &Descriptor,
jar: &Path,
dep_jars: &[PathBuf],
extra_args: &[String],
) -> Result<()> {
let image_ref = docker_build(project_root, desc, jar, dep_jars)?;
println!("Running container {} (--rm)", image_ref);
println!();
let mut cmd = Command::new("docker");
cmd.arg("run").arg("--rm").arg(&image_ref);
for arg in extra_args {
cmd.arg(arg);
}
let status = cmd
.status()
.context("failed to invoke docker run — is Docker installed?")?;
if !status.success() {
let code = status.code().unwrap_or(1);
std::process::exit(code);
}
Ok(())
}
fn stamp_path(target_dir: &Path) -> PathBuf {
target_dir.join(".docker-stamp")
}
fn touch_stamp(target_dir: &Path) -> Result<()> {
let path = stamp_path(target_dir);
std::fs::write(&path, b"").with_context(|| format!("failed to write {}", path.display()))
}
fn generated_dockerfile_inputs(target_dir: &Path, jar_filename: &str, has_libs: bool) -> Inputs {
let mut inputs = Inputs::new();
inputs
.add_file(&target_dir.join("Dockerfile"))
.add_file(&target_dir.join(".dockerignore"))
.add_file(&target_dir.join(jar_filename));
if has_libs {
inputs.add_dir(&target_dir.join("libs"));
}
inputs
}
fn user_dockerfile_inputs(project_root: &Path, jar: &Path) -> Inputs {
let mut inputs = Inputs::new();
inputs
.add_file(&project_root.join("Dockerfile"))
.add_file(&project_root.join(".dockerignore"))
.add_file(jar);
let libs_dir = project_root.join("target").join("libs");
if libs_dir.exists() {
inputs.add_dir(&libs_dir);
}
inputs
}
fn build_with_user_dockerfile(
project_root: &Path,
_desc: &Descriptor,
jar: &Path,
image_ref: &str,
) -> Result<()> {
println!(" Dockerfile using project root Dockerfile");
let target_dir = project_root.join("target");
std::fs::create_dir_all(&target_dir).context("failed to create target/")?;
let inputs = user_dockerfile_inputs(project_root, jar);
if Stamp::of(&stamp_path(&target_dir)).covers(&inputs) {
println!(" Docker image up to date");
return Ok(());
}
println!(" Docker image building {}", image_ref);
let jar_rel = jar
.strip_prefix(project_root)
.unwrap_or(jar)
.to_string_lossy()
.to_string();
let status = Command::new("docker")
.arg("build")
.arg("--build-arg")
.arg(format!("JAR_FILE={}", jar_rel))
.arg("-t")
.arg(image_ref)
.arg(project_root)
.status()
.context("failed to invoke docker build — is Docker installed?")?;
if !status.success() {
bail!("docker build failed");
}
touch_stamp(&target_dir)?;
println!(" Docker image {}", image_ref);
Ok(())
}
fn build_with_generated_dockerfile(
project_root: &Path,
desc: &Descriptor,
jar: &Path,
dep_jars: &[PathBuf],
image_ref: &str,
) -> Result<()> {
let target_dir = project_root.join("target");
std::fs::create_dir_all(&target_dir).context("failed to create target/")?;
let jar_filename = jar
.file_name()
.context("JAR path has no filename")?
.to_string_lossy()
.to_string();
if !dep_jars.is_empty() {
let libs_dir = target_dir.join("libs");
std::fs::create_dir_all(&libs_dir).context("failed to create target/libs")?;
let mut copied = 0usize;
let mut skipped = 0usize;
for dep in dep_jars {
let fname = dep
.file_name()
.context("dep JAR path has no filename")?
.to_string_lossy()
.to_string();
let dest = libs_dir.join(&fname);
if mtime(dep) > mtime(&dest) {
std::fs::copy(dep, &dest).with_context(|| {
format!(
"failed to copy dep JAR {} to {}",
dep.display(),
dest.display()
)
})?;
copied += 1;
} else {
skipped += 1;
}
}
match (copied, skipped) {
(0, _) => println!(" Docker dep JARs up to date"),
(c, 0) => println!(" Docker dep JARs {} copied", c),
(c, s) => println!(" Docker dep JARs {} copied, {} up to date", c, s),
}
}
let dockerfile_content =
generate_dockerfile(&desc.docker.base_image, &jar_filename, !dep_jars.is_empty());
let dockerfile_path = target_dir.join("Dockerfile");
let existing = std::fs::read_to_string(&dockerfile_path).unwrap_or_default();
if existing == dockerfile_content {
println!(" Dockerfile up to date");
} else {
std::fs::write(&dockerfile_path, &dockerfile_content)
.context("failed to write generated Dockerfile")?;
println!(" Dockerfile generated (target/Dockerfile)");
}
let dockerignore_content = generate_dockerignore(&jar_filename, !dep_jars.is_empty());
let dockerignore_path = target_dir.join(".dockerignore");
let existing_ignore = std::fs::read_to_string(&dockerignore_path).unwrap_or_default();
if existing_ignore == dockerignore_content {
println!(" .dockerignore up to date");
} else {
std::fs::write(&dockerignore_path, &dockerignore_content)
.context("failed to write .dockerignore")?;
println!(" .dockerignore generated (target/.dockerignore)");
}
let has_libs = !dep_jars.is_empty();
let stamp = stamp_path(&target_dir);
let inputs = generated_dockerfile_inputs(&target_dir, &jar_filename, has_libs);
if Stamp::of(&stamp).covers(&inputs) {
println!(" Docker image up to date");
return Ok(());
}
println!(" Docker image building {}", image_ref);
let status = Command::new("docker")
.arg("build")
.arg("-t")
.arg(image_ref)
.arg(&target_dir)
.status()
.context("failed to invoke docker build — is Docker installed?")?;
if !status.success() {
bail!("docker build failed");
}
touch_stamp(&target_dir)?;
println!(" Docker image {}", image_ref);
Ok(())
}
fn generate_dockerignore(jar_filename: &str, has_libs: bool) -> String {
let mut lines = vec!["*".to_string(), format!("!{}", jar_filename)];
if has_libs {
lines.push("!libs/".to_string());
}
lines.join("\n") + "\n"
}
fn generate_dockerfile(base_image: &str, jar_filename: &str, has_libs: bool) -> String {
let mut lines = vec![
format!("FROM {}", base_image),
"WORKDIR /app".to_string(),
];
if has_libs {
lines.push("COPY libs/ libs/".to_string());
}
lines.push(format!("COPY {} app.jar", jar_filename));
lines.push("ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]".to_string());
lines.join("\n") + "\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dockerignore_no_libs() {
let content = generate_dockerignore("myapp-1.0.jar", false);
assert_eq!(content, "*\n!myapp-1.0.jar\n");
}
#[test]
fn dockerignore_with_libs() {
let content = generate_dockerignore("myapp-1.0.jar", true);
assert_eq!(content, "*\n!myapp-1.0.jar\n!libs/\n");
}
#[test]
fn dockerfile_no_deps() {
let content = generate_dockerfile("eclipse-temurin:21-jre", "myapp-1.0.jar", false);
assert_eq!(
content,
"FROM eclipse-temurin:21-jre\n\
WORKDIR /app\n\
COPY myapp-1.0.jar app.jar\n\
ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]\n"
);
}
#[test]
fn dockerfile_with_deps() {
let content = generate_dockerfile("eclipse-temurin:21-jre", "myapp-1.0.jar", true);
assert_eq!(
content,
"FROM eclipse-temurin:21-jre\n\
WORKDIR /app\n\
COPY libs/ libs/\n\
COPY myapp-1.0.jar app.jar\n\
ENTRYPOINT [\"java\", \"-jar\", \"app.jar\"]\n"
);
}
#[test]
fn stamp_skip_logic() {
use filetime::{set_file_mtime, FileTime};
use std::fs;
let dir = tempfile::tempdir().unwrap();
let target = dir.path();
let jar = target.join("app-1.0.jar");
let dockerfile = target.join("Dockerfile");
let dockerignore = target.join(".dockerignore");
let stamp = stamp_path(target);
let t0 = FileTime::from_unix_time(1_000_000, 0);
let t1 = FileTime::from_unix_time(1_000_001, 0);
for path in &[&jar, &dockerfile, &dockerignore] {
fs::write(path, b"").unwrap();
set_file_mtime(path, t0).unwrap();
}
let inputs = generated_dockerfile_inputs(target, "app-1.0.jar", false);
assert!(!Stamp::of(&stamp).covers(&inputs));
fs::write(&stamp, b"").unwrap();
set_file_mtime(&stamp, t1).unwrap();
assert!(Stamp::of(&stamp).covers(&inputs));
set_file_mtime(&jar, FileTime::from_unix_time(1_000_002, 0)).unwrap();
let inputs = generated_dockerfile_inputs(target, "app-1.0.jar", false);
assert!(!Stamp::of(&stamp).covers(&inputs));
set_file_mtime(&jar, t1).unwrap(); let inputs = generated_dockerfile_inputs(target, "app-1.0.jar", false);
assert!(
!Stamp::of(&stamp).covers(&inputs),
"tied input mtime must not be considered covered",
);
}
}