use clap::{Args, Subcommand};
use eval_containers::bake;
use eval_containers::naming::{
agent_bake_target, agent_image, benchmark_bake_target, benchmark_image, benchmark_task_image,
compose_artifact, eval_task_image, flatten_imagestream, model_bake_target, model_image,
};
use std::io::Write;
use std::process::{Command, Stdio};
#[derive(Args)]
pub struct BuildArgs {
#[command(subcommand)]
pub target: BuildTarget,
#[arg(long, global = true)]
pub builder: Option<String>,
#[arg(long, global = true)]
pub dry_run: bool,
#[arg(long, global = true, default_value = "")]
pub imagestream_suffix: String,
}
#[derive(Subcommand)]
pub enum BuildTarget {
Agent { name: String },
Bench {
benchmark: String,
#[arg(long)]
task_id: Option<String>,
},
Model { name: String },
Eval {
benchmark: String,
#[arg(long)]
agent: String,
#[arg(long)]
task_id: Option<String>,
#[arg(long, default_value = "")]
agent_version: String,
#[arg(long, default_value = "gpt-5.4--bifrost")]
model: String,
},
Compose {
benchmark: String,
},
}
pub fn execute(registry: &str, args: BuildArgs) -> Result<(), String> {
let builder = args.builder.as_deref();
let dry_run = args.dry_run;
if builder == Some("oc") {
return oc_execute(args.target, dry_run, &args.imagestream_suffix);
}
if let (Some(name), false) = (builder, dry_run) {
ensure_builder(name)?;
}
match args.target {
BuildTarget::Agent { name } => {
bake(registry, &agent_bake_target(&name), &[], builder, dry_run)
}
BuildTarget::Bench { benchmark, task_id } => {
if let Some(tid) = task_id {
if builder.is_some() {
return Err("--builder applies to bake-based builds; per-task variants \
(--task-id) use plain `docker build` and can't target a \
remote builder"
.into());
}
let image = benchmark_task_image(registry, &benchmark, &tid, "latest");
let script = format!("benchmarks/{benchmark}/build.sh");
if std::path::Path::new(&script).is_file() {
run_build_script(&script, &image, &tid, dry_run)
} else {
docker_build(
&image,
&format!("./benchmarks/{benchmark}"),
&[format!("EVAL_TASK_ID={tid}")],
dry_run,
)
}
} else {
bake(
registry,
&benchmark_bake_target(&benchmark),
&[],
builder,
dry_run,
)
}
}
BuildTarget::Model { name } => {
bake(registry, &model_bake_target(&name), &[], builder, dry_run)
}
BuildTarget::Eval {
benchmark,
agent,
task_id,
agent_version,
model,
} => {
let tag = std::env::var("TAG").unwrap_or_else(|_| "latest".to_string());
let bench_tag = if let Some(ref tid) = task_id {
benchmark_task_image(registry, &benchmark, tid, &tag)
} else {
benchmark_image(registry, &benchmark, &tag)
};
let agent_tag = agent_image(registry, &agent, &tag);
let model_tag = model_image(registry, &model, &tag);
let bake_env = vec![
("EVAL_BENCHMARK", benchmark.clone()),
("EVAL_AGENT", agent.clone()),
];
let mut overrides = vec![
format!("eval.args.BENCHMARK_IMAGE={bench_tag}"),
format!("eval.args.AGENT_IMAGE={agent_tag}"),
format!("eval.args.MODEL_IMAGE={model_tag}"),
];
if let Some(ref tid) = task_id {
overrides.push(format!(
"eval.tags={}",
eval_task_image(registry, &benchmark, tid, &agent, &tag)
));
}
if !agent_version.is_empty() {
overrides.push(format!("eval.args.AGENT_VERSION={agent_version}"));
}
bake_with_env(registry, "eval", &overrides, &bake_env, builder, dry_run)
}
BuildTarget::Compose { benchmark } => {
if builder.is_some() {
return Err(
"--builder does not apply to `build compose` (it publishes a \
compose file, not an image)"
.into(),
);
}
if benchmark == "all" {
let entries = std::fs::read_dir("./benchmarks")
.map_err(|e| format!("failed to read benchmarks dir: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("failed to read entry: {e}"))?;
let name = entry.file_name().to_string_lossy().to_string();
let compose = format!("./benchmarks/{name}/compose.yaml");
if std::path::Path::new(&compose).exists() {
let tag = compose_artifact(registry, &name);
docker_compose_publish(&compose, &tag, dry_run)?;
}
}
Ok(())
} else {
let compose = format!("./benchmarks/{benchmark}/compose.yaml");
let tag = compose_artifact(registry, &benchmark);
docker_compose_publish(&compose, &tag, dry_run)
}
}
}
}
fn bake(
registry: &str,
target: &str,
overrides: &[String],
builder: Option<&str>,
dry_run: bool,
) -> Result<(), String> {
bake_with_env(registry, target, overrides, &[], builder, dry_run)
}
fn bake_with_env(
registry: &str,
target: &str,
overrides: &[String],
env: &[(&str, String)],
builder: Option<&str>,
dry_run: bool,
) -> Result<(), String> {
let override_refs: Vec<&str> = overrides.iter().map(String::as_str).collect();
let args = bake::base_args(&[target], &override_refs, builder);
let mut shown = format!("REGISTRY={registry} ");
if std::env::var("HF_TOKEN").is_ok() {
shown.push_str("HF_TOKEN=$HF_TOKEN ");
}
for (k, v) in env {
shown.push_str(&format!("{k}={v} "));
}
shown.push_str("docker ");
shown.push_str(&args.join(" "));
eprintln!("$ {shown}");
if dry_run {
return Ok(());
}
let mut cmd = Command::new("docker");
cmd.args(&args);
cmd.env("REGISTRY", registry);
if let Ok(t) = std::env::var("HF_TOKEN") {
cmd.env("HF_TOKEN", t);
}
for (k, v) in env {
cmd.env(k, v);
}
let status = cmd
.status()
.map_err(|e| format!("failed to run docker buildx bake: {e}"))?;
if !status.success() {
return Err(format!("docker buildx bake failed with {status}"));
}
Ok(())
}
fn ensure_builder(name: &str) -> Result<(), String> {
let exists = Command::new("docker")
.args(["buildx", "inspect", name])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| format!("failed to run docker buildx inspect: {e}"))?
.success();
if exists {
return Ok(());
}
Err(format!(
"buildx builder '{name}' not found. Create it once (after `oc login`):\n \
docker buildx create --driver kubernetes --name {name} --use"
))
}
fn docker_compose_publish(compose_file: &str, tag: &str, dry_run: bool) -> Result<(), String> {
eprintln!("$ docker compose -f {compose_file} publish {tag}");
if dry_run {
return Ok(());
}
let status = Command::new("docker")
.args(["compose", "-f", compose_file, "publish", tag])
.status()
.map_err(|e| format!("failed to run docker compose: {e}"))?;
if !status.success() {
return Err(format!("docker compose publish failed with {status}"));
}
Ok(())
}
fn docker_build(
tag: &str,
context: &str,
build_args: &[String],
dry_run: bool,
) -> Result<(), String> {
let mut shown = format!("docker build -t {tag}");
for arg in build_args {
shown.push_str(&format!(" --build-arg {arg}"));
}
if std::env::var("HF_TOKEN").is_ok() {
shown.push_str(" --build-arg HF_TOKEN=$HF_TOKEN");
}
shown.push_str(&format!(" {context}"));
eprintln!("$ {shown}");
if dry_run {
return Ok(());
}
let mut cmd = Command::new("docker");
cmd.arg("build").arg("-t").arg(tag);
for arg in build_args {
cmd.arg("--build-arg").arg(arg);
}
if std::env::var("HF_TOKEN").is_ok() {
cmd.arg("--build-arg").arg("HF_TOKEN");
}
cmd.arg(context);
let mut last_err = String::new();
for attempt in 1..=3 {
let status = cmd
.status()
.map_err(|e| format!("failed to run docker: {e}"))?;
if status.success() {
return Ok(());
}
last_err = format!("docker build failed with {status}");
if attempt < 3 {
eprintln!("retry {attempt}/3 after build failure");
}
}
Err(last_err)
}
fn run_build_script(script: &str, image: &str, task_id: &str, dry_run: bool) -> Result<(), String> {
eprintln!("$ bash {script} {image} {task_id}");
if dry_run {
return Ok(());
}
let status = Command::new("bash")
.args([script, image, task_id])
.status()
.map_err(|e| format!("failed to run {script}: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{script} failed with {status}"))
}
}
#[derive(serde::Deserialize)]
struct BakeTargetSpec {
context: Option<String>,
dockerfile: Option<String>,
tags: Option<Vec<String>>,
args: Option<std::collections::BTreeMap<String, String>>,
}
#[derive(serde::Deserialize)]
struct BakePrint {
target: std::collections::BTreeMap<String, BakeTargetSpec>,
}
fn split_ref<'a>(full: &'a str, registry: &str) -> (&'a str, &'a str) {
let no_reg = full.strip_prefix(&format!("{registry}/")).unwrap_or(full);
no_reg.rsplit_once(':').unwrap_or((no_reg, "latest"))
}
fn flatten_ref(full: &str, registry: &str) -> String {
let (repo, tag) = split_ref(full, registry);
format!("{registry}/{}:{}", flatten_imagestream(repo), tag)
}
fn oc_capture(args: &[&str]) -> Result<String, String> {
let out = Command::new("oc")
.args(args)
.output()
.map_err(|e| format!("failed to run oc {}: {e}", args.join(" ")))?;
if !out.status.success() {
return Err(format!(
"oc {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn bake_print(
bake_target: &str,
overrides: &[String],
registry: &str,
env: &[(&str, String)],
) -> Result<BakeTargetSpec, String> {
let mut args: Vec<String> = vec!["buildx".into(), "bake".into()];
for f in bake::artifact_bake_files() {
args.push("-f".into());
args.push(f.to_string_lossy().into_owned());
}
args.push("--print".into());
for o in overrides {
args.push("--set".into());
args.push(o.clone());
}
args.push(bake_target.to_string());
let mut cmd = Command::new("docker");
cmd.args(&args).env("REGISTRY", registry);
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd
.output()
.map_err(|e| format!("failed to run docker buildx bake --print: {e}"))?;
if !out.status.success() {
return Err(format!(
"docker buildx bake --print failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let parsed: BakePrint = serde_json::from_slice(&out.stdout)
.map_err(|e| format!("failed to parse bake --print JSON: {e}"))?;
parsed
.target
.into_iter()
.find(|(name, _)| name == bake_target)
.map(|(_, spec)| spec)
.ok_or_else(|| format!("bake --print has no target '{bake_target}'"))
}
fn oc_execute(target: BuildTarget, dry_run: bool, is_suffix: &str) -> Result<(), String> {
let ir = if dry_run {
"image-registry.openshift-image-registry.svc:5000/NAMESPACE".to_string()
} else {
format!(
"{}/{}",
oc_capture(&["registry", "info"])?,
oc_capture(&["project", "-q"])?
)
};
let (bake_target, overrides, env): (String, Vec<String>, Vec<(&str, String)>) = match target {
BuildTarget::Agent { name } => (agent_bake_target(&name), vec![], vec![]),
BuildTarget::Bench { benchmark, task_id } => {
if task_id.is_some() {
return Err(
"--builder oc does not support --task-id; per-task variants \
use plain `docker build` (BAKE.md)"
.into(),
);
}
(benchmark_bake_target(&benchmark), vec![], vec![])
}
BuildTarget::Model { name } => (model_bake_target(&name), vec![], vec![]),
BuildTarget::Eval {
benchmark,
agent,
task_id,
agent_version,
model,
} => {
if task_id.is_some() {
return Err("--builder oc does not support --task-id".into());
}
let bench_is = format!(
"{}{is_suffix}",
flatten_imagestream(&format!("benchmarks/{benchmark}"))
);
let agent_is = format!(
"{}{is_suffix}",
flatten_imagestream(&format!("agents/{agent}"))
);
let model_is = format!(
"{}{is_suffix}",
flatten_imagestream(&format!("models/{model}"))
);
let mut overrides = vec![
format!("eval.args.BENCHMARK_IMAGE={ir}/{bench_is}:latest"),
format!("eval.args.AGENT_IMAGE={ir}/{agent_is}:latest"),
format!("eval.args.MODEL_IMAGE={ir}/{model_is}:latest"),
];
if !agent_version.is_empty() {
overrides.push(format!("eval.args.AGENT_VERSION={agent_version}"));
}
let env = vec![("EVAL_BENCHMARK", benchmark), ("EVAL_AGENT", agent)];
("eval".to_string(), overrides, env)
}
BuildTarget::Compose { .. } => {
return Err("--builder oc does not apply to `build compose`".into());
}
};
let spec = bake_print(&bake_target, &overrides, &ir, &env)?;
let tag = spec
.tags
.as_ref()
.and_then(|t| t.first())
.ok_or_else(|| format!("bake target '{bake_target}' has no tags"))?;
let (repo, _) = split_ref(tag, &ir);
let imagestream = format!("{}{is_suffix}", flatten_imagestream(repo));
let context = spec.context.clone().unwrap_or_else(|| ".".into());
let dockerfile = spec
.dockerfile
.clone()
.unwrap_or_else(|| "Dockerfile".into());
let mut build_args = vec![format!("REGISTRY={ir}"), "REGISTRY_SUFFIX=-".to_string()];
if let Some(args) = spec.args {
for (k, v) in args {
if v.is_empty() {
continue;
}
let v = if v.starts_with(&format!("{ir}/")) {
flatten_ref(&v, &ir)
} else {
v
};
build_args.push(format!("{k}={v}"));
}
}
oc_build(&imagestream, &context, &dockerfile, &build_args, dry_run)
}
fn oc_build(
imagestream: &str,
context: &str,
dockerfile: &str,
build_args: &[String],
dry_run: bool,
) -> Result<(), String> {
let mut args_yaml = String::new();
for kv in build_args {
let (k, v) = kv.split_once('=').unwrap_or((kv.as_str(), ""));
args_yaml.push_str(&format!(" - {{ name: {k}, value: \"{v}\" }}\n"));
}
let storage = if dockerfile.contains("combination") {
"10Gi"
} else {
"4Gi"
};
let bc = format!(
"apiVersion: build.openshift.io/v1\n\
kind: BuildConfig\n\
metadata:\n name: {imagestream}-bc\n\
spec:\n\
\x20 successfulBuildsHistoryLimit: 0\n\
\x20 failedBuildsHistoryLimit: 1\n\
\x20 source:\n type: Binary\n binary: {{}}\n\
\x20 strategy:\n type: Docker\n dockerStrategy:\n dockerfilePath: {dockerfile}\n buildArgs:\n{args_yaml}\
\x20 resources:\n requests: {{ephemeral-storage: \"{storage}\"}}\n limits: {{ephemeral-storage: \"{storage}\"}}\n\
\x20 output:\n to:\n kind: ImageStreamTag\n name: {imagestream}:latest\n"
);
eprintln!("$ oc create imagestream {imagestream} 2>/dev/null || true");
eprintln!("$ oc apply -f - <<'EOF'\n{bc}EOF");
eprintln!("$ oc start-build {imagestream}-bc --from-dir {context} --follow");
if dry_run {
return Ok(());
}
let _ = Command::new("oc")
.args(["create", "imagestream", imagestream])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let mut child = Command::new("oc")
.args(["apply", "-f", "-"])
.stdin(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to run oc apply: {e}"))?;
child
.stdin
.take()
.ok_or("failed to open oc apply stdin")?
.write_all(bc.as_bytes())
.map_err(|e| format!("failed to write BuildConfig: {e}"))?;
if !child
.wait()
.map_err(|e| format!("oc apply wait failed: {e}"))?
.success()
{
return Err("oc apply BuildConfig failed".into());
}
let status = Command::new("oc")
.args([
"start-build",
&format!("{imagestream}-bc"),
"--from-dir",
context,
"--follow",
])
.status()
.map_err(|e| format!("failed to run oc start-build: {e}"))?;
if !status.success() {
return Err(format!("oc start-build failed with {status}"));
}
Ok(())
}