ref:main
use crate::runner::log_reporter::LogReporter;
use sha2::{Digest, Sha256};
use std::process::Command;
use tokio::io::{AsyncBufReadExt, BufReader};
/// Compute a deterministic image tag from the base image and prepare commands.
pub fn compute_prepared_tag(image: &str, prepare: &[String]) -> String {
let mut hasher = Sha256::new();
hasher.update(image.as_bytes());
for cmd in prepare {
hasher.update(b"\n");
hasher.update(cmd.as_bytes());
}
let hash = hasher.finalize();
let hex = format!("{hash:x}");
format!("anvil-prepared:{}", &hex[..12])
}
/// Check if a Docker image exists locally.
fn image_exists_locally(tag: &str) -> bool {
Command::new("docker")
.args(["image", "inspect", tag])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Prepare a Docker image by running prepare commands on the base image.
/// Returns the tag of the prepared image (either cached or freshly built).
pub async fn prepare_image(
image: &str,
prepare: &[String],
log_reporter: &LogReporter,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let tag = compute_prepared_tag(image, prepare);
if image_exists_locally(&tag) {
let msg = format!("Using cached prepared image: {tag}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
return Ok(tag);
}
log_reporter
.append(&format!("Building prepared image from {image}..."))
.await;
// Pull the base image first
eprintln!("Pulling base image: {image}");
log_reporter
.append(&format!("Pulling base image: {image}"))
.await;
let pull_output = Command::new("docker").args(["pull", image]).output();
match pull_output {
Ok(output) if output.status.success() => {
eprintln!("Base image ready: {image}");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = format!("Warning: docker pull failed: {stderr}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
}
Err(e) => {
let msg = format!("Warning: docker pull error: {e}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
}
}
// Join all prepare commands with && so they run in sequence and fail fast
let combined = prepare.join(" && ");
let container_name = format!("anvil-prepare-{}", &tag["anvil-prepared:".len()..]);
// Remove any leftover container from a previous failed attempt
let _ = Command::new("docker")
.args(["rm", "-f", &container_name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
// Run prepare commands in the base image, streaming output
log_reporter.append("Running prepare commands...").await;
let mut child = tokio::process::Command::new("docker")
.args([
"run",
"--name",
&container_name,
image,
"/bin/sh",
"-c",
&combined,
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
// Stream stdout and stderr
let reporter_out = log_reporter.clone();
let stdout = child.stdout.take();
let stdout_task = tokio::spawn(async move {
if let Some(stdout) = stdout {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
reporter_out.append(&line).await;
}
}
});
let reporter_err = log_reporter.clone();
let stderr = child.stderr.take();
let stderr_task = tokio::spawn(async move {
if let Some(stderr) = stderr {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
reporter_err.append(&line).await;
}
}
});
let _ = stdout_task.await;
let _ = stderr_task.await;
let status = child.wait().await?;
log_reporter.flush().await;
if !status.success() {
// Clean up failed container
let _ = Command::new("docker")
.args(["rm", "-f", &container_name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
return Err(format!(
"Prepare commands failed with exit code {}",
status.code().unwrap_or(1)
)
.into());
}
// Commit the container as the prepared image
log_reporter
.append(&format!("Committing prepared image as {tag}..."))
.await;
let commit_output = Command::new("docker")
.args(["commit", &container_name, &tag])
.output()?;
// Remove the build container
let _ = Command::new("docker")
.args(["rm", "-f", &container_name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if !commit_output.status.success() {
let stderr = String::from_utf8_lossy(&commit_output.stderr);
return Err(format!("docker commit failed: {stderr}").into());
}
let msg = format!("Prepared image ready: {tag}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
Ok(tag)
}
/// Prune old prepared images beyond max_count or older than max_age_days.
pub fn prune_prepared_images(max_age_days: u64, max_count: usize) {
let output = Command::new("docker")
.args([
"images",
"anvil-prepared",
"--format",
"{{.ID}} {{.CreatedAt}} {{.Repository}}:{{.Tag}}",
])
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return,
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut images: Vec<(&str, &str)> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() >= 3 {
Some((parts[2], parts[1]))
} else {
None
}
})
.collect();
// Sort by creation date descending (newest first) — lexicographic on ISO date works
images.sort_by(|a, b| b.1.cmp(a.1));
// Remove images beyond max_count
for (tag, _) in images.iter().skip(max_count) {
eprintln!("Pruning old prepared image: {tag}");
let _ = Command::new("docker")
.args(["rmi", tag])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
// Also remove images older than max_age_days
if max_age_days > 0 {
let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days as i64);
let cutoff_str = cutoff.format("%Y-%m-%d").to_string();
for (tag, created_at) in &images {
if *created_at < cutoff_str.as_str() {
eprintln!("Pruning expired prepared image: {tag}");
let _ = Command::new("docker")
.args(["rmi", tag])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_prepared_tag_deterministic() {
let tag1 = compute_prepared_tag(
"elixir:1.17",
&["apt-get update".into(), "mix local.hex --force".into()],
);
let tag2 = compute_prepared_tag(
"elixir:1.17",
&["apt-get update".into(), "mix local.hex --force".into()],
);
assert_eq!(tag1, tag2);
assert!(tag1.starts_with("anvil-prepared:"));
assert_eq!(tag1.len(), "anvil-prepared:".len() + 12);
}
#[test]
fn test_compute_prepared_tag_differs_on_image() {
let tag1 = compute_prepared_tag("elixir:1.17", &["apt-get update".into()]);
let tag2 = compute_prepared_tag("elixir:1.18", &["apt-get update".into()]);
assert_ne!(tag1, tag2);
}
#[test]
fn test_compute_prepared_tag_differs_on_commands() {
let tag1 = compute_prepared_tag("elixir:1.17", &["apt-get update".into()]);
let tag2 = compute_prepared_tag("elixir:1.17", &["apt-get install -y git".into()]);
assert_ne!(tag1, tag2);
}
#[test]
fn test_compute_prepared_tag_order_matters() {
let tag1 = compute_prepared_tag("elixir:1.17", &["cmd1".into(), "cmd2".into()]);
let tag2 = compute_prepared_tag("elixir:1.17", &["cmd2".into(), "cmd1".into()]);
assert_ne!(tag1, tag2);
}
}