use std::path::{Path, PathBuf};
use std::process::Stdio;
use async_trait::async_trait;
use tokio::process::Command;
use xbp_deploy::{OciBuildAdapter, OciBuildRequest, OciBuildResult};
use crate::utils::command_exists;
pub struct CliOciBuildAdapter;
#[async_trait]
impl OciBuildAdapter for CliOciBuildAdapter {
async fn build_and_push(&self, request: OciBuildRequest) -> Result<OciBuildResult, String> {
if !command_exists("docker") {
return Err(
"docker CLI not found — install Docker to build images during deploy, or pass --skip-build"
.into(),
);
}
let dockerfile = request
.dockerfile
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("Dockerfile");
let context = resolve_context(&request);
let dockerfile_path = resolve_dockerfile(&request.project_root, &context, dockerfile);
if !dockerfile_path.is_file() {
return Err(format!(
"Dockerfile not found: {} (context={})",
dockerfile_path.display(),
context.display()
));
}
if !context.is_dir() {
return Err(format!("build context is not a directory: {}", context.display()));
}
let mut lines = Vec::new();
lines.push(format!(
"build image={} dockerfile={} context={} mode={}",
request.image_ref,
dockerfile_path.display(),
context.display(),
if request.local_image {
"local-load"
} else {
"push"
}
));
if request.dry_run {
if request.local_image {
lines.push(format!(
"dry-run: docker build -t {} -f {} {} (local load — no push)",
request.image_ref,
dockerfile_path.display(),
context.display()
));
} else {
lines.push(format!(
"dry-run: docker build -t {} -f {} {}",
request.image_ref,
dockerfile_path.display(),
context.display()
));
lines.push(format!("dry-run: docker push {}", request.image_ref));
}
return Ok(OciBuildResult {
lines,
digest: None,
image_ref: Some(request.image_ref.clone()),
local_only: request.local_image,
});
}
if request.local_image {
let mut result =
classic_build_local(&request, &dockerfile_path, &context).await?;
prepend_lines(&mut result.lines, &lines);
return Ok(result);
}
let multi_platform = needs_multi_platform(&request.platforms);
if multi_platform {
if !command_exists_docker_buildx().await {
return Err(
"multi-platform build requested but docker buildx is unavailable — install buildx, clear services[].oci.platforms, or use --local-image for a single-platform local build"
.into(),
);
}
let mut result =
buildx_push(&request, &dockerfile_path, &context).await?;
prepend_lines(&mut result.lines, &lines);
return Ok(result);
}
if command_exists_docker_buildx().await {
match buildx_push(&request, &dockerfile_path, &context).await {
Ok(mut result) => {
prepend_lines(&mut result.lines, &lines);
return Ok(result);
}
Err(e) => {
lines.push(format!(
"buildx --push failed; falling back to docker build + docker push: {e}"
));
let el = e.to_ascii_lowercase();
if el.contains("denied")
|| el.contains("unauthorized")
|| el.contains("authentication required")
|| el.contains("no basic auth credentials")
{
lines.push(
"hint: use --local-image (or accept the prompt) to build into local Docker without registry push"
.into(),
);
}
}
}
}
let mut result =
classic_build_and_push(&request, &dockerfile_path, &context).await?;
prepend_lines(&mut result.lines, &lines);
Ok(result)
}
}
fn prepend_lines(target: &mut Vec<String>, prefix: &[String]) {
if prefix.is_empty() {
return;
}
let mut merged = prefix.to_vec();
merged.append(target);
*target = merged;
}
fn needs_multi_platform(platforms: &[String]) -> bool {
let filtered: Vec<&str> = platforms
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
filtered.len() > 1
}
async fn buildx_push(
request: &OciBuildRequest,
dockerfile_path: &Path,
context: &Path,
) -> Result<OciBuildResult, String> {
let mut out = Vec::new();
let mut args = vec![
"buildx".into(),
"build".into(),
"-t".into(),
request.image_ref.clone(),
"-f".into(),
dockerfile_path.display().to_string(),
"--push".into(),
"--progress=plain".into(),
];
if !request.platforms.is_empty() {
args.push("--platform".into());
args.push(request.platforms.join(","));
}
let meta_path = std::env::temp_dir().join(format!(
"xbp-build-meta-{}-{}.json",
std::process::id(),
request.service.replace('/', "-")
));
args.push("--metadata-file".into());
args.push(meta_path.display().to_string());
args.push(context.display().to_string());
out.push(format!(
"docker buildx build --push -t {} …",
request.image_ref
));
run_docker(&args).await?;
out.push(format!("pushed {} (buildx)", request.image_ref));
let (digest, pinned) = read_buildx_digest(&meta_path, &request.image_ref);
if let Some(ref d) = digest {
out.push(format!("digest={d}"));
}
let _ = std::fs::remove_file(&meta_path);
Ok(OciBuildResult {
lines: out,
digest,
image_ref: Some(pinned),
local_only: false,
})
}
async fn classic_build_local(
request: &OciBuildRequest,
dockerfile_path: &Path,
context: &Path,
) -> Result<OciBuildResult, String> {
let mut out = Vec::new();
let platform = request
.platforms
.iter()
.map(|s| s.trim())
.find(|s| !s.is_empty())
.map(str::to_string);
let mut args = vec![
"build".into(),
"-t".into(),
request.image_ref.clone(),
"-f".into(),
dockerfile_path.display().to_string(),
"--progress=plain".into(),
];
if let Some(p) = &platform {
args.push("--platform".into());
args.push(p.clone());
out.push(format!(
"docker build --platform {p} -t {} -f {} {} (local — no push)",
request.image_ref,
dockerfile_path.display(),
context.display()
));
} else {
out.push(format!(
"docker build -t {} -f {} {} (local — no push)",
request.image_ref,
dockerfile_path.display(),
context.display()
));
}
args.push(context.display().to_string());
match run_docker(&args).await {
Ok(()) => {
out.push(format!("built local image {}", request.image_ref));
}
Err(e) => {
if command_exists_docker_buildx().await {
out.push(format!(
"docker build failed; trying buildx --load: {e}"
));
let mut bx = vec![
"buildx".into(),
"build".into(),
"-t".into(),
request.image_ref.clone(),
"-f".into(),
dockerfile_path.display().to_string(),
"--load".into(),
"--progress=plain".into(),
];
if let Some(p) = &platform {
bx.push("--platform".into());
bx.push(p.clone());
}
bx.push(context.display().to_string());
run_docker(&bx).await?;
out.push(format!(
"built local image {} (buildx --load)",
request.image_ref
));
} else {
return Err(e);
}
}
}
let local_id = inspect_image_id(&request.image_ref).await;
if let Some(ref id) = local_id {
out.push(format!("local image id={id}"));
}
out.push(
"image loaded into local Docker — use docker-desktop/kind/minikube that shares this daemon"
.into(),
);
Ok(OciBuildResult {
lines: out,
digest: local_id,
image_ref: Some(request.image_ref.clone()),
local_only: true,
})
}
async fn classic_build_and_push(
request: &OciBuildRequest,
dockerfile_path: &Path,
context: &Path,
) -> Result<OciBuildResult, String> {
let mut out = Vec::new();
out.push(format!(
"docker build -t {} -f {} {}",
request.image_ref,
dockerfile_path.display(),
context.display()
));
run_docker(&[
"build".into(),
"-t".into(),
request.image_ref.clone(),
"-f".into(),
dockerfile_path.display().to_string(),
"--progress=plain".into(),
context.display().to_string(),
])
.await?;
out.push(format!("built {}", request.image_ref));
out.push(format!("docker push {}", request.image_ref));
run_docker(&["push".into(), request.image_ref.clone()]).await?;
out.push(format!("pushed {}", request.image_ref));
let digest = inspect_image_digest(&request.image_ref).await;
let pinned = pin_image_ref(&request.image_ref, digest.as_deref());
if let Some(ref d) = digest {
out.push(format!("digest={d}"));
}
Ok(OciBuildResult {
lines: out,
digest,
image_ref: Some(pinned),
local_only: false,
})
}
async fn inspect_image_id(image_ref: &str) -> Option<String> {
let output = Command::new("docker")
.args([
"image",
"inspect",
"--format",
"{{.Id}}",
image_ref,
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn pin_image_ref(image_ref: &str, digest: Option<&str>) -> String {
match digest {
Some(d) if d.starts_with("sha256:") => {
let repo = image_ref
.rsplit_once(':')
.map(|(r, _)| r)
.filter(|r| !r.contains('@'))
.unwrap_or(image_ref);
let repo = repo.rsplit_once('@').map(|(r, _)| r).unwrap_or(repo);
format!("{repo}@{d}")
}
_ => image_ref.to_string(),
}
}
fn read_buildx_digest(meta_path: &Path, image_ref: &str) -> (Option<String>, String) {
let Ok(raw) = std::fs::read_to_string(meta_path) else {
return (None, image_ref.to_string());
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
return (None, image_ref.to_string());
};
let digest = v
.get("containerimage.digest")
.and_then(|d| d.as_str())
.or_else(|| {
v.pointer("/containerimage.descriptor/digest")
.and_then(|d| d.as_str())
})
.map(str::to_string);
let pinned = match &digest {
Some(d) if d.starts_with("sha256:") => {
let repo = image_ref
.rsplit_once(':')
.map(|(r, _)| r)
.filter(|r| !r.contains('@'))
.unwrap_or(image_ref);
let repo = repo.rsplit_once('@').map(|(r, _)| r).unwrap_or(repo);
format!("{repo}@{d}")
}
_ => image_ref.to_string(),
};
(digest, pinned)
}
async fn inspect_image_digest(image_ref: &str) -> Option<String> {
let output = Command::new("docker")
.args([
"image",
"inspect",
"--format",
"{{index .RepoDigests 0}}",
image_ref,
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
s.rsplit_once('@').map(|(_, d)| d.to_string())
}
fn resolve_context(request: &OciBuildRequest) -> PathBuf {
if request.context.is_absolute() {
return request.context.clone();
}
if let Some(root) = &request.service_root {
let joined = root.join(&request.context);
if joined.is_dir() || request.context.as_os_str().is_empty() {
return if request.context.as_os_str().is_empty() {
root.clone()
} else {
joined
};
}
}
let joined = request.project_root.join(&request.context);
if joined.is_dir() {
return joined;
}
if request.context.as_os_str().is_empty()
|| request.context == Path::new(".")
|| request.context == Path::new("")
{
return request.project_root.clone();
}
joined
}
fn resolve_dockerfile(project_root: &Path, context: &Path, dockerfile: &str) -> PathBuf {
let p = PathBuf::from(dockerfile);
if p.is_absolute() {
return p;
}
let in_context = context.join(&p);
if in_context.is_file() {
return in_context;
}
let in_project = project_root.join(&p);
if in_project.is_file() {
return in_project;
}
in_context
}
async fn command_exists_docker_buildx() -> bool {
Command::new("docker")
.args(["buildx", "version"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
async fn run_docker(args: &[String]) -> Result<(), String> {
let output = Command::new("docker")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("failed to start docker: {e}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if !stderr.trim().is_empty() {
stderr.trim()
} else {
stdout.trim()
};
let cmd = args
.iter()
.take(6)
.cloned()
.collect::<Vec<_>>()
.join(" ");
Err(humanize_docker_failure(&cmd, detail))
}
fn humanize_docker_failure(cmd: &str, detail: &str) -> String {
let lower = detail.to_ascii_lowercase();
let mut hints = Vec::new();
if lower.contains("denied")
|| lower.contains("unauthorized")
|| lower.contains("authentication required")
|| lower.contains("no basic auth credentials")
{
hints.push(
"registry auth failed — run `docker login ghcr.io` (or the image registry) and retry",
);
}
if lower.contains("toomanyrequests")
|| lower.contains("rate limit")
|| lower.contains("429")
{
hints.push("Docker Hub rate limit — log in with `docker login` or wait and retry");
}
if lower.contains("failed to resolve")
|| lower.contains("no such host")
|| lower.contains("connection refused")
|| lower.contains("i/o timeout")
|| lower.contains("tls handshake")
{
hints.push("network/registry unreachable — check VPN/DNS and Docker Desktop network");
}
if lower.contains("load metadata") && lower.contains("rust:") {
hints.push(
"stuck/failed pulling base image metadata — retry, `docker pull rust:1-bookworm`, or check Docker Hub access",
);
}
if lower.contains("error response from daemon") && lower.contains("desktop") {
hints.push("Docker Desktop may be unhealthy — restart Docker Desktop and retry");
}
if cmd.contains("buildx") && cmd.contains("--push") {
hints.push(
"buildx --push requires registry write access; verify `docker login` for the image host, or re-run with --local-image to load into local Docker only",
);
}
if lower.contains("denied") || lower.contains("unauthorized") {
hints.push(
"for local kubernetes (docker-desktop/kind/minikube): xbp deploy … --local-image --run",
);
}
let body = truncate(detail, 2_400);
if hints.is_empty() {
format!("docker {cmd} failed: {body}")
} else {
format!(
"docker {cmd} failed: {body}\nHint: {}",
hints.join("; ")
)
}
}
fn truncate(s: &str, max: usize) -> String {
let t = s.trim();
if t.chars().count() <= max {
t.to_string()
} else {
let chars: Vec<char> = t.chars().collect();
let start = chars.len().saturating_sub(max.saturating_sub(1));
format!("…{}", chars[start..].iter().collect::<String>())
}
}