diff --git a/.changes/849.json b/.changes/849.json new file mode 100644 index 000000000..d12b8c494 --- /dev/null +++ b/.changes/849.json @@ -0,0 +1,6 @@ +[ + { + "description": "enable using `buildx bake` and `buildx imagetools` for building cross images with `build-docker-image`", + "type": "internal" + } +] diff --git a/src/docker/image.rs b/src/docker/image.rs index 266c5813c..ae0fd79b8 100644 --- a/src/docker/image.rs +++ b/src/docker/image.rs @@ -157,6 +157,9 @@ impl std::str::FromStr for ImagePlatform { value::{Error as SerdeError, StrDeserializer}, IntoDeserializer, }; + if s.contains(',') { + eyre::bail!("invalid platform, found unexpected character `,`"); + } if let Some((platform, toolchain)) = s.split_once('=') { let image_toolchain = toolchain.into(); let (os, arch, variant) = if let Some((os, rest)) = platform.split_once('/') { diff --git a/src/docker/mod.rs b/src/docker/mod.rs index cd80b20ce..f7f497523 100644 --- a/src/docker/mod.rs +++ b/src/docker/mod.rs @@ -32,7 +32,9 @@ impl ProvidedImage { } pub fn image_name(target: &str, sub: Option<&str>, repository: &str, tag: &str) -> String { - if let Some(sub) = sub { + if tag.is_empty() { + format!("{repository}/{target}") + } else if let Some(sub) = sub { format!("{repository}/{target}:{tag}-{sub}") } else { format!("{repository}/{target}:{tag}") diff --git a/xtask/src/build_docker_image.rs b/xtask/src/build_docker_image.rs index 70f120feb..3e898e2f3 100644 --- a/xtask/src/build_docker_image.rs +++ b/xtask/src/build_docker_image.rs @@ -1,4 +1,6 @@ +use std::collections::BTreeMap; use std::fmt::Write; +use std::io::Write as _; use std::path::Path; use crate::util::{cargo_metadata, gha_error, gha_output, gha_print}; @@ -6,6 +8,8 @@ use clap::Args; use cross::docker::ImagePlatform; use cross::shell::MessageInfo; use cross::{docker, CommandExt, ToUtf8}; +use eyre::Context; +use serde::Serialize; #[derive(Args, Debug)] pub struct BuildDockerImage { @@ -52,6 +56,12 @@ pub struct BuildDockerImage { default_value = "auto" )] pub progress: String, + /// Use a bake build, specifying multiple platforms makes this implicit + #[clap(long)] + pub bake: bool, + /// With bake action, append to the manifest instead of overwriting it. + #[clap(long, requires = "bake")] + pub append: bool, /// Do not load from cache when building the image. #[clap(long)] pub no_cache: bool, @@ -75,6 +85,31 @@ pub struct BuildDockerImage { pub targets: Vec, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct BakeTarget { + #[serde(skip, default)] + image_target: Option, + #[serde(skip_serializing_if = "<[_]>::is_empty")] + inherits: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dockerfile: Option, + #[serde(skip_serializing_if = "<[_]>::is_empty")] + tags: Vec, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + labels: BTreeMap, + #[serde(skip_serializing_if = "<[_]>::is_empty")] + platforms: Vec, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + args: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + no_cache: Option, + #[serde(skip_serializing_if = "<[_]>::is_empty")] + cache_from: Vec, +} + fn locate_dockerfile( target: crate::ImageTarget, docker_root: &Path, @@ -106,6 +141,8 @@ pub fn build_docker_image( push, no_output, progress, + bake, + append, no_cache, no_fastfail, from_ci, @@ -160,153 +197,375 @@ pub fn build_docker_image( .collect::>>()?; let platforms = if platform.is_empty() { - vec![ImagePlatform::DEFAULT] + if let Ok(platforms) = std::env::var("PLATFORMS") { + platforms + .split(',') + .map(|p| p.parse()) + .collect::>()? + } else { + vec![ImagePlatform::DEFAULT] + } } else { platform }; + if push && tag_override.is_none() && ref_name.is_none() { + panic!("Refusing to push without tag or branch. Specify a repository and tag with `--repository --tag `") + } + + let progress = if gha || progress == "plain" { + "plain" + } else { + &progress + }; + + let labels = labels + .as_deref() + .unwrap_or("") + .split('\n') + .filter(|s| !s.is_empty()) + .collect::>(); let mut results = vec![]; - for (platform, (target, dockerfile)) in targets - .iter() - .flat_map(|t| platforms.iter().map(move |p| (p, t))) - { - if gha && targets.len() > 1 { - gha_print("::group::Build {target}"); - } else { - msg_info.note(format_args!("Build {target} for {}", platform.target))?; - } - let mut docker_build = docker::command(engine); - docker_build.args(&["buildx", "build"]); - docker_build.current_dir(&docker_root); - docker_build.args(&["--platform", &platform.docker_platform()]); + let bake = bake || platforms.len() > 1; - if push { - docker_build.arg("--push"); - } else if engine.kind.is_docker() && no_output { - docker_build.args(&["--output", "type=tar,dest=/dev/null"]); - } else { - docker_build.arg("--load"); - } + if !bake { + for (platform, (target, dockerfile)) in targets + .iter() + .flat_map(|t| platforms.iter().map(move |p| (p, t))) + { + if gha && targets.len() > 1 { + gha_print("::group::Build {target}"); + } else { + msg_info.note(format_args!("Build {target} for {}", platform.target))?; + } + let mut docker_build = docker::command(engine); + docker_build.args(&["buildx", "build"]); + docker_build.current_dir(&docker_root); - let mut tags = vec![]; + docker_build.args(&["--platform", &platform.docker_platform()]); - match (ref_type.as_deref(), ref_name.as_deref()) { - (Some(ref_type), Some(ref_name)) => tags.extend(determine_image_name( + if push { + if let Some("") = tag_override.as_deref() { + docker_build.args(&["--output", "type=registry,push-by-digest=true"]); + } else { + docker_build.args(&["--output", "type=registry"]); + } + docker_build.args(&["--cache-to", "type=inline"]); + } else if no_output { + docker_build.args(&["--output", "type=tar,dest=/dev/null"]); + } else { + docker_build.args(&["--output", "type=docker"]); + } + + let tags = get_tags( target, &repository, - ref_type, - ref_name, - is_latest, &version, - )?), - _ => { - if push && tag_override.is_none() { - panic!("Refusing to push without tag or branch. Specify a repository and tag with `--repository --tag `") - } - tags.push(target.image_name(&repository, "local")); + is_latest, + ref_type.as_deref(), + ref_name.as_deref(), + tag_override.as_deref(), + )?; + + docker_build.arg("--pull"); + if no_cache { + docker_build.arg("--no-cache"); + } else { + docker_build.args(&[ + "--cache-from", + &format!( + "type=registry,ref={}", + target.image_name(&repository, "main") + ), + ]); } - } - if let Some(ref tag) = tag_override { - tags = vec![target.image_name(&repository, tag)]; - } + for tag in &tags { + docker_build.args(&["--tag", tag]); + } - docker_build.arg("--pull"); - if no_cache { - docker_build.arg("--no-cache"); - } else { - docker_build.args(&[ - "--cache-from", - &format!( - "type=registry,ref={}", - target.image_name(&repository, "main") - ), - ]); - } + for label in &labels { + docker_build.args(&["--label", label]); + } - if push { - docker_build.args(&["--cache-to", "type=inline"]); - } + for label in get_default_labels(target, platform) { + docker_build.args(&["--label", &format!("{}={}", label.0, label.1)]); + } - for tag in &tags { - docker_build.args(&["--tag", tag]); - } + docker_build.args(&["-f", dockerfile]); + docker_build.args(&["--progress", progress]); - for label in labels - .as_deref() - .unwrap_or("") - .split('\n') - .filter(|s| !s.is_empty()) - { - docker_build.args(&["--label", label]); - } + for arg in &build_arg { + docker_build.args(&["--build-arg", arg]); + } - docker_build.args([ - "--label", - &format!( - "{}.for-cross-target={}", - cross::CROSS_LABEL_DOMAIN, - target.name - ), - ]); - docker_build.args([ - "--label", - &format!( - "{}.runs-with={}", - cross::CROSS_LABEL_DOMAIN, - platform.target - ), - ]); - - docker_build.args(&["-f", dockerfile]); - - if gha || progress == "plain" { - docker_build.args(&["--progress", "plain"]); - } else { - docker_build.args(&["--progress", &progress]); + if verbose > 1 { + docker_build.args(&["--build-arg", "VERBOSE=1"]); + } + + if target.needs_workspace_root_context() { + docker_build.arg(&root); + } else { + docker_build.arg("."); + } + + if !dry_run && (force || !push || gha) { + let result = docker_build.run(msg_info, false); + if gha && targets.len() > 1 { + if let Err(e) = &result { + // TODO: Determine what instruction errorred, and place warning on that line with appropriate warning + gha_error(&format!("file=docker/{dockerfile},title=Build failed::{e}")); + } + } + results.push( + result + .map(|_| target.clone()) + .map_err(|e| (target.clone(), e)), + ); + if !no_fastfail && results.last().unwrap().is_err() { + break; + } + } else { + docker_build.print(msg_info)?; + if !dry_run { + msg_info.fatal("refusing to push, use --force to override", 1); + } + } + if gha { + gha_output("image", &tags[0]); + gha_output("images", &format!("'{}'", serde_json::to_string(&tags)?)); + if targets.len() > 1 { + gha_print("::endgroup::"); + } + } } - for arg in &build_arg { - docker_build.args(&["--build-arg", arg]); + } else { + // Bake + let labels = labels + .into_iter() + .map(|s| -> cross::Result<_> { + let s = s + .split_once('=') + .ok_or_else(|| eyre::eyre!("invalid label `{s}`"))?; + Ok((s.0.to_string(), s.1.to_string())) + }) + .collect::>>()?; + let build_args = build_arg + .into_iter() + .map(|s| -> cross::Result<_> { + let s = s + .split_once('=') + .ok_or_else(|| eyre::eyre!("invalid build arg `{s}`"))?; + Ok((s.0.to_string(), s.1.to_string())) + }) + .collect::>>()?; + let mut defaults = vec![]; + let mut bake_targets = targets + .iter() + .map(|(target, dockerfile)| -> cross::Result<_> { + Ok(( + target.to_string().replace('.', "-"), + BakeTarget { + image_target: None, + inherits: vec!["base".to_owned()], + dockerfile: Some(dockerfile.clone()), + context: if target.needs_workspace_root_context() { + Some(root.to_utf8()?.to_owned()) + } else { + None + }, + tags: vec![], + labels: labels.clone(), + platforms: vec![], + args: build_args.clone(), + no_cache: None, + cache_from: if !no_cache { + vec![format!( + "type=registry,ref={}", + target.image_name(&repository, "main") + )] + } else { + vec![] + }, + }, + )) + }) + .collect::>>()?; + for platform in &platforms { + for (target, _dockerfile) in &targets { + let name = format!("{target}-{}", platform.docker_platform()) + .replace('.', "-") + .replace('/', "-"); + let target = BakeTarget { + image_target: Some(target.clone()), + inherits: vec![target.to_string()], + dockerfile: None, + context: None, + tags: get_tags( + target, + &repository, + &version, + is_latest, + ref_type.as_deref(), + ref_name.as_deref(), + tag_override.as_deref(), + )? + .into_iter() + .map(|mut tag| { + if tag.contains(':') { + write!( + &mut tag, + "-{}", + platform.docker_platform().replace('/', "-") + ) + .expect("string write should not fail"); + tag + } else { + tag + } + }) + .collect(), + labels: get_default_labels(target, platform).into_iter().collect(), + platforms: vec![platform.docker_platform()], + args: build_args.clone(), + no_cache: None, + cache_from: if !no_cache { + vec![format!( + "type=registry,ref={}", + target.image_name(&repository, "main") + )] + } else { + vec![] + }, + }; + bake_targets.insert(name.clone(), target); + defaults.push(name); + } } + + bake_targets.insert( + "base".to_owned(), + BakeTarget { + image_target: None, + inherits: vec![], + context: Some(".".to_owned()), + dockerfile: None, + tags: vec![], + labels, + platforms: vec![], + args: build_args, + no_cache: Some(no_cache), + cache_from: vec![], + }, + ); + let mut docker_bake = docker::command(engine); + docker_bake.args(&["buildx", "bake"]); + docker_bake.current_dir(&docker_root); + docker_bake.arg("--pull"); + + docker_bake.args(&["--progress", progress]); + if verbose > 1 { - docker_build.args(&["--build-arg", "VERBOSE=1"]); + docker_bake.args(&["--set", "*.args.VERBOSE=1"]); } - if target.needs_workspace_root_context() { - docker_build.arg(&root); + if push { + if let Some("") = tag_override.as_deref() { + docker_bake.args(&["--set", "*.output=type=registry,push-by-digest=true"]); + } else { + docker_bake.args(&["--set", "*.output=type=registry"]); + } + //docker_bake.args(&["--set", "*.cache-to=type=inline"]); + } else if no_output { + docker_bake.args(&["--set", "*.output=type=tar,dest=/dev/null"]); } else { - docker_build.arg("."); + // if multi-platform, this will fail. + docker_bake.args(&["--set", "*.output=type=docker"]); } - if !dry_run && (force || !push || gha) { - let result = docker_build.run(msg_info, false); - if gha && targets.len() > 1 { - if let Err(e) = &result { - // TODO: Determine what instruction errorred, and place warning on that line with appropriate warning - gha_error(&format!("file=docker/{dockerfile},title=Build failed::{e}")); + if dry_run { + docker_bake.arg("--print"); + } + + let mut temp_build_def = unsafe { cross::temp::TempFile::new()? }; + let mut temp_metadata = unsafe { cross::temp::TempFile::new()? }; + let content = serde_json::to_string_pretty(&serde_json::json!({ + "group": { + "default": { + "targets": defaults, + } + }, + "target": bake_targets, + }))?; + write!(temp_build_def.file(), "{}", content).wrap_err("couldn't write to temp file")?; + docker_bake.args(&["-f", temp_build_def.file().path().to_utf8()?]); + docker_bake.args(&["--metadata-file", temp_metadata.file().path().to_utf8()?]); + docker_bake.run(msg_info, false)?; + + if dry_run { + return Ok(()); + } + let metadata: serde_json::Value = + serde_json::from_str(&cross::file::read(temp_metadata.path())?)?; + + let images = { + let mut bake_images: BTreeMap> = BTreeMap::new(); + for (name, target) in bake_targets + .iter() + .filter_map(|(name, target)| -> Option<_> { + // Only get bake targets with a specific target + Some((name, target.image_target.as_ref()?)) + }) + { + if let Some(image) = metadata.pointer(&format!("/{name}/containerimage.digest")) { + bake_images.entry(target.clone()).or_default().push( + image + .as_str() + .ok_or_else(|| eyre::eyre!("digest should be a string"))? + .to_owned(), + ); } } - results.push( - result - .map(|_| target.clone()) - .map_err(|e| (target.clone(), e)), - ); - if !no_fastfail && results.last().unwrap().is_err() { - break; - } - } else { - docker_build.print(msg_info)?; - if !dry_run { - msg_info.fatal("refusing to push, use --force to override", 1); + let mut images = vec![]; + for (target, digest) in bake_images { + let mut docker_itc = docker::command(engine); + docker_itc.args(&["buildx", "imagetools", "create"]); + + if append { + docker_itc.arg("--append"); + } + + if !push || dry_run { + docker_itc.arg("--dry-run"); + } + + docker_itc.args( + digest + .into_iter() + .map(|digest| format!("{}@{digest}", target.image_name(&repository, ""))), + ); + let tags = get_tags( + &target, + &repository, + &version, + is_latest, + ref_type.as_deref(), + ref_name.as_deref(), + tag_override.as_deref(), + )?; + for tag in &tags { + docker_itc.args(&["--tag", tag]); + } + images.extend(tags); + + docker_itc.run(msg_info, false)?; } - } + images + }; if gha { - gha_output("image", &tags[0]); - gha_output("images", &format!("'{}'", serde_json::to_string(&tags)?)); - if targets.len() > 1 { - gha_print("::endgroup::"); - } + gha_output("image", &images[0]); + gha_output("images", &format!("'{}'", serde_json::to_string(&images)?)); } } if gha { @@ -321,6 +580,46 @@ pub fn build_docker_image( Ok(()) } +fn get_default_labels( + target: &crate::ImageTarget, + platform: &ImagePlatform, +) -> Vec<(String, String)> { + vec![ + ( + format!("{}.for-cross-target", cross::CROSS_LABEL_DOMAIN), + target.name.clone(), + ), + ( + format!("{}.runs-with", cross::CROSS_LABEL_DOMAIN), + platform.target.to_string(), + ), + ] +} +pub fn get_tags( + target: &crate::ImageTarget, + repository: &str, + version: &str, + is_latest: bool, + ref_type: Option<&str>, + ref_name: Option<&str>, + tag_override: Option<&str>, +) -> cross::Result> { + if let Some(tag) = tag_override { + return Ok(vec![target.image_name(repository, tag)]); + } + + let mut tags = vec![]; + match (ref_type, ref_name) { + (Some(ref_type), Some(ref_name)) => tags.extend(determine_image_name( + target, repository, ref_type, ref_name, is_latest, version, + )?), + _ => { + tags.push(target.image_name(repository, "local")); + } + } + Ok(tags) +} + pub fn determine_image_name( target: &crate::ImageTarget, repository: &str, diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 106d43762..cec2b2930 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -90,6 +90,7 @@ macro_rules! get_msg_info { pub fn main() -> cross::Result<()> { cross::install_panic_hook()?; + cross::install_termination_hook()?; let cli = Cli::parse(); match cli.command { Commands::TargetInfo(args) => {