1use std::path::{Path, PathBuf};
2
3use blue_build_process_management::{
4 drivers::{
5 BuildDriver, CiDriver, Driver, DriverArgs, SigningDriver,
6 opts::{
7 BuildTagPushOpts, CheckKeyPairOpts, CompressionType, GenerateImageNameOpts,
8 GenerateTagsOpts, SignVerifyOpts,
9 },
10 types::Platform,
11 },
12 logging::{color_str, gen_random_ansi_color},
13};
14use blue_build_recipe::Recipe;
15use blue_build_utils::{
16 constants::{
17 ARCHIVE_SUFFIX, BB_REGISTRY_NAMESPACE, BB_SKIP_VALIDATION, CONFIG_PATH, CONTAINER_FILE,
18 RECIPE_FILE, RECIPE_PATH,
19 },
20 cowstr,
21 credentials::{Credentials, CredentialsArgs},
22 string,
23 traits::CowCollecter,
24};
25use bon::Builder;
26use clap::Args;
27use log::{debug, info, trace, warn};
28use miette::{IntoDiagnostic, Result, bail};
29use oci_distribution::Reference;
30use rayon::prelude::*;
31use tempfile::TempDir;
32
33use crate::commands::generate::GenerateCommand;
34
35use super::BlueBuildCommand;
36
37#[allow(clippy::struct_excessive_bools)]
38#[derive(Debug, Args, Builder)]
39pub struct BuildCommand {
40 #[arg()]
42 #[builder(into)]
43 recipe: Option<Vec<PathBuf>>,
44
45 #[arg(short, long, group = "archive_push")]
51 #[builder(default)]
52 push: bool,
53
54 #[arg(long)]
61 platform: Option<Platform>,
62
63 #[arg(short, long, default_value_t = CompressionType::Gzip)]
66 #[builder(default)]
67 compression_format: CompressionType,
68
69 #[arg(short, long)]
71 #[builder(default)]
72 retry_push: bool,
73
74 #[arg(long, default_value_t = 1)]
76 #[builder(default)]
77 retry_count: u8,
78
79 #[arg(short, long, group = "archive_rechunk", group = "archive_push")]
82 #[builder(into)]
83 archive: Option<PathBuf>,
84
85 #[arg(long, env = BB_REGISTRY_NAMESPACE, visible_alias("registry-path"))]
88 #[builder(into)]
89 registry_namespace: Option<String>,
90
91 #[arg(long)]
93 #[builder(default)]
94 no_sign: bool,
95
96 #[arg(short, long)]
104 #[builder(default)]
105 squash: bool,
106
107 #[arg(long, group = "archive_rechunk", env = blue_build_utils::constants::BB_BUILD_RECHUNK)]
115 #[builder(default)]
116 rechunk: bool,
117
118 #[arg(long, env = blue_build_utils::constants::BB_BUILD_RECHUNK_CLEAR_PLAN)]
122 #[builder(default)]
123 rechunk_clear_plan: bool,
124
125 #[arg(long)]
128 tempdir: Option<PathBuf>,
129
130 #[builder(default)]
134 #[arg(long, env = blue_build_utils::constants::BB_CACHE_LAYERS)]
135 cache_layers: bool,
136
137 #[arg(long, env = BB_SKIP_VALIDATION)]
139 #[builder(default)]
140 skip_validation: bool,
141
142 #[clap(flatten)]
143 #[builder(default)]
144 credentials: CredentialsArgs,
145
146 #[clap(flatten)]
147 #[builder(default)]
148 drivers: DriverArgs,
149}
150
151impl BlueBuildCommand for BuildCommand {
152 fn try_run(&mut self) -> Result<()> {
154 trace!("BuildCommand::try_run()");
155
156 Driver::init(self.drivers);
157
158 Credentials::init(self.credentials.clone());
159
160 if self.push && self.archive.is_some() {
161 bail!("You cannot use '--archive' and '--push' at the same time");
162 }
163
164 if self.push {
165 blue_build_utils::check_command_exists("cosign")?;
166 Driver::check_signing_files(&CheckKeyPairOpts::builder().dir(Path::new(".")).build())?;
167 Driver::login()?;
168 Driver::signing_login()?;
169 }
170
171 let tempdir = if let Some(ref dir) = self.tempdir {
172 TempDir::new_in(dir).into_diagnostic()?
173 } else {
174 TempDir::new().into_diagnostic()?
175 };
176 let recipe_paths = self.recipe.clone().map_or_else(|| {
177 let legacy_path = Path::new(CONFIG_PATH);
178 let recipe_path = Path::new(RECIPE_PATH);
179 if recipe_path.exists() && recipe_path.is_dir() {
180 vec![recipe_path.join(RECIPE_FILE)]
181 } else {
182 warn!("Use of {CONFIG_PATH} for recipes is deprecated, please move your recipe files into {RECIPE_PATH}");
183 vec![legacy_path.join(RECIPE_FILE)]
184 }
185 },
186 |recipes| {
187 let mut same = std::collections::HashSet::new();
188
189 recipes.into_iter().filter(|recipe| same.insert(recipe.clone())).collect()
190 });
191
192 recipe_paths.par_iter().try_for_each(|recipe| {
193 GenerateCommand::builder()
194 .output(tempdir.path().join(if recipe_paths.len() > 1 {
195 blue_build_utils::generate_containerfile_path(recipe)?
196 } else {
197 PathBuf::from(CONTAINER_FILE)
198 }))
199 .skip_validation(self.skip_validation)
200 .maybe_platform(self.platform)
201 .recipe(recipe)
202 .drivers(self.drivers)
203 .build()
204 .try_run()
205 })?;
206
207 self.start(&recipe_paths, tempdir.path())
208 }
209}
210
211impl BuildCommand {
212 fn start(&self, recipe_paths: &[PathBuf], temp_dir: &Path) -> Result<()> {
213 use rayon::prelude::*;
214
215 trace!("BuildCommand::build_image()");
216
217 let images = recipe_paths
218 .par_iter()
219 .try_fold(Vec::new, |mut images, recipe_path| -> Result<Vec<String>> {
220 let containerfile = temp_dir.join(if recipe_paths.len() > 1 {
221 blue_build_utils::generate_containerfile_path(recipe_path)?
222 } else {
223 PathBuf::from(CONTAINER_FILE)
224 });
225 images.extend(self.build(recipe_path, &containerfile)?);
226 Ok(images)
227 })
228 .try_reduce(Vec::new, |mut init, image_names| {
229 let color = gen_random_ansi_color();
230 init.extend(image_names.iter().map(|image| color_str(image, color)));
231 Ok(init)
232 })?;
233
234 info!(
235 "Finished building:\n{}",
236 images
237 .iter()
238 .map(|image| format!("\t- {image}"))
239 .collect::<Vec<_>>()
240 .join("\n")
241 );
242 Ok(())
243 }
244
245 fn build(&self, recipe_path: &Path, containerfile: &Path) -> Result<Vec<String>> {
246 let recipe = Recipe::parse(recipe_path)?;
247 let tags = Driver::generate_tags(
248 &GenerateTagsOpts::builder()
249 .oci_ref(&recipe.base_image_ref()?)
250 .maybe_alt_tags(recipe.alt_tags.as_ref().map(CowCollecter::collect_cow_vec))
251 .maybe_platform(self.platform)
252 .build(),
253 )?;
254 let image_name = self.image_name(&recipe)?;
255 let image: Reference = format!("{image_name}:{}", tags.first().map_or("latest", |tag| tag))
256 .parse()
257 .into_diagnostic()?;
258 let cache_image = (self.cache_layers && self.push).then(|| {
259 let cache_image = Reference::with_tag(
260 image.registry().to_string(),
261 image.repository().to_string(),
262 format!(
263 "{}-cache",
264 image.tag().expect("Reference should be built with tag")
265 ),
266 );
267 debug!("Using {cache_image} for caching layers");
268 cache_image
269 });
270
271 let images = if self.rechunk {
272 self.rechunk(
273 containerfile,
274 &recipe,
275 &tags,
276 &image_name,
277 cache_image.as_ref(),
278 )?
279 } else {
280 Driver::build_tag_push(&self.archive.as_ref().map_or_else(
281 || {
282 BuildTagPushOpts::builder()
283 .image(&image)
284 .containerfile(containerfile)
285 .maybe_platform(self.platform)
286 .tags(tags.collect_cow_vec())
287 .push(self.push)
288 .retry_push(self.retry_push)
289 .retry_count(self.retry_count)
290 .compression(self.compression_format)
291 .squash(self.squash)
292 .maybe_cache_from(cache_image.as_ref())
293 .maybe_cache_to(cache_image.as_ref())
294 .secrets(recipe.get_secrets())
295 .build()
296 },
297 |archive_dir| {
298 BuildTagPushOpts::builder()
299 .containerfile(containerfile)
300 .maybe_platform(self.platform)
301 .image(PathBuf::from(format!(
302 "{}/{}.{ARCHIVE_SUFFIX}",
303 archive_dir.to_string_lossy().trim_end_matches('/'),
304 recipe.name.to_lowercase().replace('/', "_"),
305 )))
306 .squash(self.squash)
307 .maybe_cache_from(cache_image.as_ref())
308 .maybe_cache_to(cache_image.as_ref())
309 .secrets(recipe.get_secrets())
310 .build()
311 },
312 ))?
313 };
314
315 if self.push && !self.no_sign {
316 Driver::sign_and_verify(
317 &SignVerifyOpts::builder()
318 .image(&image)
319 .retry_push(self.retry_push)
320 .retry_count(self.retry_count)
321 .maybe_platform(self.platform)
322 .build(),
323 )?;
324 }
325
326 Ok(images)
327 }
328
329 fn rechunk(
330 &self,
331 containerfile: &Path,
332 recipe: &Recipe<'_>,
333 tags: &[String],
334 image_name: &str,
335 cache_image: Option<&Reference>,
336 ) -> Result<Vec<String>, miette::Error> {
337 use blue_build_process_management::drivers::{
338 InspectDriver, RechunkDriver,
339 opts::{GetMetadataOpts, RechunkOpts},
340 };
341 let base_image: Reference = format!("{}:{}", &recipe.base_image, &recipe.image_version)
342 .parse()
343 .into_diagnostic()?;
344 Driver::rechunk(
345 &RechunkOpts::builder()
346 .image(image_name)
347 .containerfile(containerfile)
348 .maybe_platform(self.platform)
349 .tags(tags.collect_cow_vec())
350 .push(self.push)
351 .version(format!(
352 "{version}.<date>",
353 version = Driver::get_os_version()
354 .oci_ref(&recipe.base_image_ref()?)
355 .maybe_platform(self.platform)
356 .call()?,
357 ))
358 .retry_push(self.retry_push)
359 .retry_count(self.retry_count)
360 .compression(self.compression_format)
361 .base_digest(
362 Driver::get_metadata(
363 &GetMetadataOpts::builder()
364 .image(&base_image)
365 .maybe_platform(self.platform)
366 .build(),
367 )?
368 .digest,
369 )
370 .repo(Driver::get_repo_url()?)
371 .name(&*recipe.name)
372 .description(&*recipe.description)
373 .base_image(format!("{}:{}", &recipe.base_image, &recipe.image_version))
374 .maybe_tempdir(self.tempdir.as_deref())
375 .clear_plan(self.rechunk_clear_plan)
376 .maybe_cache_from(cache_image)
377 .maybe_cache_to(cache_image)
378 .secrets(recipe.get_secrets())
379 .build(),
380 )
381 }
382
383 fn image_name(&self, recipe: &Recipe) -> Result<String> {
384 let image_name = Driver::generate_image_name(
385 GenerateImageNameOpts::builder()
386 .name(recipe.name.trim())
387 .maybe_registry(self.credentials.registry.as_ref().map(|r| cowstr!(r)))
388 .maybe_registry_namespace(self.registry_namespace.as_ref().map(|r| cowstr!(r)))
389 .build(),
390 )?;
391
392 let image_name = if image_name.registry().is_empty() {
393 string!(image_name.repository())
394 } else if image_name.registry() == "" {
395 image_name.repository().to_string()
396 } else {
397 format!("{}/{}", image_name.registry(), image_name.repository())
398 };
399
400 Ok(image_name)
401 }
402}