ref:4ace024ac59301bc2c5f04cfceed7d13fedae686

feat(runner): support `command:` field on services

A service declared in .anvil.yml could only set image/env/ports — no way to override the container's CMD. Critical for images whose default entrypoint requires args, e.g. minio/minio:latest needs `server /data` or it prints help and exits (code 0). When that happens the service container vanishes before the test container can reach it, surfacing as `:nxdomain` from inside the test. Read `command` from the service spec (string → split on whitespace, or list → passed verbatim) and append after the image. Extract the arg-building into `build_run_args/4` so the wiring is unit-testable without spinning up containers. Verified end-to-end locally: minio service stays alive, peer container resolves `minio` over the docker network, TCP connect to 9000 succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SHA: 4ace024ac59301bc2c5f04cfceed7d13fedae686
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-30 13:57
Parents: e7810d8
1 files changed +127 -33
Type
src/runner/service_manager.rs +127 −33
@@ -110,40 +110,9 @@
.get("image")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("service '{svc_name}' missing 'image'"))?;
let container_name = format!("anvil-ci-svc-{job_id}-{svc_name}");
let mut args = vec![
"run".to_string(),
"--detach".into(),
"--name".into(),
container_name.clone(),
"--network".into(),
network.clone(),
"--network-alias".into(),
svc_name.clone(),
];
// Add environment variables
if let Some(env_map) = svc_config.get("env").and_then(|v| v.as_object()) {
for (k, v) in env_map {
let val = v.as_str().unwrap_or("");
args.push("-e".into());
args.push(format!("{k}={val}"));
}
}
// Add port mappings
if let Some(ports) = svc_config.get("ports").and_then(|v| v.as_array()) {
for port in ports {
if let Some(p) = port.as_str() {
args.push("-p".into());
args.push(p.to_string());
}
}
}
args.push(image.to_string());
let args = build_run_args(svc_name, svc_config, &container_name, &network)
.map_err(|e| format!("service '{svc_name}': {e}"))?;
let output = Command::new("docker").args(&args).output()?;
if !output.status.success() {
@@ -216,7 +185,74 @@
}
eprintln!("warning: health check timed out after {timeout_secs}s");
}
/// Build the `docker run` argument list for one service. Pure function —
/// extracted from `start_services` so the arg-building logic (especially
/// the `command:` field that wires through to the container's CMD) is
/// unit-testable without spinning up containers.
pub fn build_run_args(
svc_name: &str,
svc_config: &serde_json::Value,
container_name: &str,
network: &str,
) -> Result<Vec<String>, String> {
let image = svc_config
.get("image")
.and_then(|v| v.as_str())
.ok_or_else(|| "missing 'image'".to_string())?;
let mut args = vec![
"run".to_string(),
"--detach".into(),
"--name".into(),
container_name.to_string(),
"--network".into(),
network.to_string(),
"--network-alias".into(),
svc_name.to_string(),
];
if let Some(env_map) = svc_config.get("env").and_then(|v| v.as_object()) {
for (k, v) in env_map {
let val = v.as_str().unwrap_or("");
args.push("-e".into());
args.push(format!("{k}={val}"));
}
}
if let Some(ports) = svc_config.get("ports").and_then(|v| v.as_array()) {
for port in ports {
if let Some(p) = port.as_str() {
args.push("-p".into());
args.push(p.to_string());
}
}
}
args.push(image.to_string());
// Optional `command:` override (e.g. `server /data` for the official
// minio/minio image, which has no default CMD that does anything
// useful — without this it prints help and exits, killing the
// service before the test container can reach it). Accept either a
// string (split on whitespace) or a list of pre-split args.
if let Some(cmd) = svc_config.get("command") {
if let Some(s) = cmd.as_str() {
for part in s.split_whitespace() {
args.push(part.to_string());
}
} else if let Some(arr) = cmd.as_array() {
for part in arr {
if let Some(s) = part.as_str() {
args.push(s.to_string());
}
}
}
}
Ok(args)
}
/// Stop and remove all service containers and the network.
pub fn cleanup_services(containers: &[String], network: &str) {
for container in containers {
@@ -228,5 +264,63 @@
let _ = Command::new("docker")
.args(["network", "rm", network])
.output();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_run_args_appends_string_command_after_image() {
// Regression: minio/minio:latest needs `server /data` as its CMD
// — without this, the previous code launched it with no args, the
// entrypoint printed help, and the container exited (code 0)
// before the test container could reach it. `command:` MUST land
// AFTER the image in the docker run argv, never before.
let svc = serde_json::json!({
"image": "minio/minio:latest",
"command": "server /data",
"env": { "MINIO_ROOT_USER": "minioadmin" }
});
let args = build_run_args("minio", &svc, "anvil-ci-svc-X-minio", "anvil-ci-X").unwrap();
let img_pos = args.iter().position(|a| a == "minio/minio:latest").unwrap();
let server_pos = args.iter().position(|a| a == "server").unwrap();
let data_pos = args.iter().position(|a| a == "/data").unwrap();
assert!(server_pos > img_pos, "command must come after image");
assert_eq!(data_pos, server_pos + 1, "command parts must stay in order");
}
#[test]
fn build_run_args_accepts_list_command() {
let svc = serde_json::json!({
"image": "busybox:latest",
"command": ["sh", "-c", "echo hello"]
});
let args = build_run_args("svc", &svc, "c", "n").unwrap();
let img_pos = args.iter().position(|a| a == "busybox:latest").unwrap();
assert_eq!(args.get(img_pos + 1), Some(&"sh".to_string()));
assert_eq!(args.get(img_pos + 2), Some(&"-c".to_string()));
assert_eq!(args.get(img_pos + 3), Some(&"echo hello".to_string()));
}
#[test]
fn build_run_args_omits_command_when_unset() {
let svc = serde_json::json!({ "image": "postgres:16" });
let args = build_run_args("postgres", &svc, "c", "n").unwrap();
// The image must be the LAST positional arg when no command override.
assert_eq!(args.last(), Some(&"postgres:16".to_string()));
}
#[test]
fn build_run_args_includes_network_alias_for_dns_resolution() {
let svc = serde_json::json!({ "image": "x" });
let args = build_run_args("alias-name", &svc, "c", "net").unwrap();
let alias_pos = args.iter().position(|a| a == "--network-alias").unwrap();
assert_eq!(args.get(alias_pos + 1), Some(&"alias-name".to_string()));
}
}