use std::path::PathBuf;
use foundation_deployment_docker::client::build_context::ContextTar;
use foundation_deployment_docker::client::images::ImageBuildOptions;
use foundation_deployment_docker::DockerClient;
use crate::docker::error::{docker_err, map_docker_client_err, DockerError, DockerResult};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum BuildBackend {
#[default]
Classic,
BuildKit(String),
}
#[derive(Debug, Clone)]
pub struct DockerFileConfig {
pub dockerfile: DockerFileSource,
pub context: PathBuf,
pub build_args: Vec<(String, String)>,
pub tag: String,
pub platform: Option<String>,
pub backend: BuildBackend,
}
#[derive(Debug, Clone)]
pub enum DockerFileSource {
Inline(String),
File(PathBuf),
}
#[derive(Debug, Clone)]
pub struct ImageBuildResult {
pub image_tag: String,
pub sha256: String,
pub was_cached: bool,
}
impl DockerFileConfig {
#[must_use]
pub fn new(dockerfile: impl Into<String>) -> Self {
Self {
dockerfile: DockerFileSource::Inline(dockerfile.into()),
context: PathBuf::from("."),
build_args: Vec::new(),
tag: String::new(),
platform: None,
backend: BuildBackend::Classic,
}
}
#[must_use]
pub fn from_file(path: impl Into<PathBuf>) -> Self {
Self {
dockerfile: DockerFileSource::File(path.into()),
context: PathBuf::from("."),
build_args: Vec::new(),
tag: String::new(),
platform: None,
backend: BuildBackend::Classic,
}
}
#[must_use]
pub fn arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.build_args.push((key.into(), value.into()));
self
}
#[must_use]
pub fn context(mut self, path: impl Into<PathBuf>) -> Self {
self.context = path.into();
self
}
#[must_use]
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tag = tag.into();
self
}
#[must_use]
pub fn platform(mut self, platform: impl Into<String>) -> Self {
self.platform = Some(platform.into());
self
}
#[must_use]
pub fn backend(mut self, backend: BuildBackend) -> Self {
self.backend = backend;
self
}
pub async fn build_once(&self) -> DockerResult<ImageBuildResult> {
if self.tag.is_empty() {
return Err(docker_err(DockerError::InvalidConfig(
"DockerFileConfig::tag is required — an untagged build cannot be cached or run"
.to_string(),
)));
}
match &self.backend {
BuildBackend::Classic => self.build_classic().await,
BuildBackend::BuildKit(addr) => self.build_buildkit(addr).await,
}
}
async fn build_classic(&self) -> DockerResult<ImageBuildResult> {
let docker = DockerClient::connect_with_defaults()
.map_err(|e| docker_err(DockerError::Connection(format!("{e}"))))?;
if let Some(sha) = image_sha(&docker, &self.tag).await? {
return Ok(ImageBuildResult {
image_tag: self.tag.clone(),
sha256: sha,
was_cached: true,
});
}
let context = self.context_tar()?;
let outcome = docker
.image_build(
&context,
&ImageBuildOptions {
tag: Some(self.tag.clone()),
build_args: self.build_args.clone(),
platform: self.platform.clone(),
force_rm: true,
..Default::default()
},
)
.await
.map_err(map_docker_client_err)?;
let sha256 = match outcome.image_id {
Some(id) => id,
None => image_sha(&docker, &self.tag).await?.unwrap_or_default(),
};
Ok(ImageBuildResult {
image_tag: self.tag.clone(),
sha256,
was_cached: false,
})
}
fn context_tar(&self) -> DockerResult<ContextTar> {
let tar_err = |e: std::io::Error| {
docker_err(DockerError::InvalidConfig(format!(
"packing build context {}: {e}",
self.context.display()
)))
};
match &self.dockerfile {
DockerFileSource::Inline(content) => {
if self.context.as_os_str() == "." && !self.context.join("Dockerfile").exists() {
ContextTar::from_inline_dockerfile("Dockerfile", content).map_err(tar_err)
} else {
ContextTar::from_dir_with_dockerfile(&self.context, "Dockerfile", content)
.map_err(tar_err)
}
}
DockerFileSource::File(path) => {
let content = std::fs::read_to_string(path).map_err(|e| {
docker_err(DockerError::InvalidConfig(format!(
"reading Dockerfile {}: {e}",
path.display()
)))
})?;
ContextTar::from_dir_with_dockerfile(&self.context, "Dockerfile", &content)
.map_err(tar_err)
}
}
}
}
async fn image_sha(docker: &DockerClient, tag: &str) -> DockerResult<Option<String>> {
match docker.image_inspect(tag).await {
Ok(info) => Ok(Some(info.id.unwrap_or_default())),
Err(foundation_deployment_docker::DockerError::Api { status: 404, .. }) => Ok(None),
Err(e) => Err(map_docker_client_err(e)),
}
}
#[cfg(not(feature = "buildkit"))]
impl DockerFileConfig {
async fn build_buildkit(&self, _addr: &str) -> DockerResult<ImageBuildResult> {
Err(docker_err(DockerError::InvalidConfig(
"BuildBackend::BuildKit needs the `buildkit` feature on \
foundation_deployment_platform"
.to_string(),
)))
}
}
#[cfg(feature = "buildkit")]
impl DockerFileConfig {
async fn build_buildkit(&self, addr: &str) -> DockerResult<ImageBuildResult> {
use foundation_deployment_docker::buildkit::session::SessionServer;
use foundation_deployment_docker::buildkit::types::SolveRequest;
use foundation_deployment_docker::buildkit::{new_build_ref, BuildKitClient};
fn bk_err(
what: &'static str,
) -> impl FnOnce(
Box<dyn std::error::Error + Send + Sync>,
) -> foundation_errstacks::ErrorTrace<DockerError> {
move |e| docker_err(DockerError::ImageBuild(format!("buildkit {what}: {e}")))
}
let client = BuildKitClient::connect_tcp(addr).map_err(|e| {
docker_err(DockerError::Connection(format!("buildkitd at {addr}: {e}")))
})?;
let builder = match &self.dockerfile {
DockerFileSource::Inline(content) => {
SessionServer::builder_inline(content).map_err(|e| {
docker_err(DockerError::InvalidConfig(format!(
"staging inline Dockerfile: {e}"
)))
})?
}
DockerFileSource::File(_) => SessionServer::builder(&self.context),
};
let session = builder
.start_with(client.transport().clone(), addr)
.await
.map_err(bk_err("session"))?;
let mut frontend_attrs = std::collections::HashMap::new();
frontend_attrs.insert("filename".to_string(), "Dockerfile".to_string());
if let Some(ref platform) = self.platform {
frontend_attrs.insert("platform".to_string(), platform.clone());
}
for (k, v) in &self.build_args {
frontend_attrs.insert(format!("build-arg:{k}"), v.clone());
}
let mut exporter_attrs = std::collections::HashMap::new();
exporter_attrs.insert("name".to_string(), self.tag.clone());
let request = SolveRequest {
Ref: new_build_ref(),
Session: session.id.clone(),
Frontend: "dockerfile.v0".to_string(),
FrontendAttrs: frontend_attrs.into_iter().collect(),
ExporterDeprecated: "image".to_string(),
ExporterAttrsDeprecated: exporter_attrs.into_iter().collect(),
..Default::default()
};
let response = client.solve(request).await.map_err(bk_err("solve"))?;
let sha256 = response
.ExporterResponse
.get("containerimage.digest")
.cloned()
.unwrap_or_default();
Ok(ImageBuildResult {
image_tag: self.tag.clone(),
sha256,
was_cached: false,
})
}
}