1use std::{
2 num::NonZeroU32,
3 ops::Not,
4 path::{Path, PathBuf},
5};
6
7use blue_build_process_management::{
8 drivers::{
9 BuildChunkedOciDriver, BuildDriver, CiDriver, Driver, DriverArgs, InspectDriver,
10 RechunkDriver, SigningDriver,
11 opts::{
12 BuildChunkedOciOpts, BuildRechunkTagPushOpts, BuildTagPushOpts, CheckKeyPairOpts,
13 CompressionType, GenerateImageNameOpts, GenerateTagsOpts, GetMetadataOpts, RechunkOpts,
14 SignVerifyOpts,
15 },
16 types::{BuildDriverType, RunDriverType},
17 },
18 logging::color_str,
19};
20use blue_build_recipe::Recipe;
21use blue_build_utils::{
22 colors::gen_random_ansi_color,
23 constants::{
24 ARCHIVE_SUFFIX, BB_BUILD_ARCHIVE, BB_BUILD_CHUNKED_OCI, BB_BUILD_CHUNKED_OCI_MAX_LAYERS,
25 BB_BUILD_NO_SIGN, BB_BUILD_PLATFORM, BB_BUILD_PUSH, BB_BUILD_RECHUNK,
26 BB_BUILD_RECHUNK_CLEAR_PLAN, BB_BUILD_REMOVE_BASE_IMAGE, BB_BUILD_RETRY_COUNT,
27 BB_BUILD_RETRY_PUSH, BB_BUILD_SQUASH, BB_CACHE_LAYERS, BB_REGISTRY_NAMESPACE,
28 BB_SKIP_VALIDATION, BB_TEMPDIR, CONFIG_PATH, DEFAULT_MAX_LAYERS, RECIPE_FILE, RECIPE_PATH,
29 },
30 container::{ImageRef, Tag},
31 credentials::{Credentials, CredentialsArgs},
32 platform::Platform,
33};
34use bon::Builder;
35use clap::Args;
36use log::{debug, info, trace, warn};
37use miette::{IntoDiagnostic, Result, bail};
38use oci_client::Reference;
39use rayon::prelude::*;
40use tempfile::TempDir;
41
42use crate::commands::generate::{GenerateCommand, generate_default_labels};
43
44use super::BlueBuildCommand;
45
46#[expect(clippy::struct_excessive_bools)]
47#[derive(Debug, Args, Builder)]
48pub struct BuildCommand {
49 #[arg()]
51 #[builder(into)]
52 recipe: Option<Vec<PathBuf>>,
53
54 #[arg(short, long, group = "archive_push", env = BB_BUILD_PUSH)]
60 #[builder(default)]
61 push: bool,
62
63 #[builder(default)]
73 #[arg(long, env = BB_BUILD_PLATFORM)]
74 platform: Vec<Platform>,
75
76 #[arg(short, long, default_value_t = CompressionType::Gzip)]
79 #[builder(default)]
80 compression_format: CompressionType,
81
82 #[arg(short, long, env = BB_BUILD_RETRY_PUSH)]
84 #[builder(default)]
85 retry_push: bool,
86
87 #[arg(long, default_value_t = 1, env = BB_BUILD_RETRY_COUNT)]
89 #[builder(default)]
90 retry_count: u8,
91
92 #[arg(short, long, group = "archive_rechunk", group = "archive_push", env = BB_BUILD_ARCHIVE)]
95 #[builder(into)]
96 archive: Option<PathBuf>,
97
98 #[arg(long, env = BB_REGISTRY_NAMESPACE, visible_alias("registry-path"))]
101 #[builder(into)]
102 registry_namespace: Option<String>,
103
104 #[arg(long, env = BB_BUILD_NO_SIGN)]
106 #[builder(default)]
107 no_sign: bool,
108
109 #[arg(short, long, env = BB_BUILD_SQUASH)]
117 #[builder(default)]
118 squash: bool,
119
120 #[arg(long, env = BB_BUILD_CHUNKED_OCI)]
126 #[builder(default)]
127 build_chunked_oci: bool,
128
129 #[arg(
131 long,
132 default_value_t = DEFAULT_MAX_LAYERS,
133 env = BB_BUILD_CHUNKED_OCI_MAX_LAYERS,
134 requires = "build_chunked_oci"
135 )]
136 #[builder(default = DEFAULT_MAX_LAYERS)]
137 max_layers: NonZeroU32,
138
139 #[arg(long, env = BB_BUILD_REMOVE_BASE_IMAGE, requires = "build_chunked_oci")]
143 #[builder(default)]
144 remove_base_image: bool,
145
146 #[arg(long, group = "archive_rechunk", env = BB_BUILD_RECHUNK)]
156 #[builder(default)]
157 rechunk: bool,
158
159 #[arg(long, env = BB_BUILD_RECHUNK_CLEAR_PLAN)]
163 #[builder(default)]
164 rechunk_clear_plan: bool,
165
166 #[arg(long, env = BB_TEMPDIR)]
169 tempdir: Option<PathBuf>,
170
171 #[builder(default)]
175 #[arg(long, env = BB_CACHE_LAYERS)]
176 cache_layers: bool,
177
178 #[arg(long, env = BB_SKIP_VALIDATION)]
180 #[builder(default)]
181 skip_validation: bool,
182
183 #[clap(flatten)]
184 #[builder(default)]
185 credentials: CredentialsArgs,
186
187 #[clap(flatten)]
188 #[builder(default)]
189 drivers: DriverArgs,
190}
191
192impl BlueBuildCommand for BuildCommand {
193 fn try_run(&mut self) -> Result<()> {
195 trace!("BuildCommand::try_run()");
196
197 Driver::init(if self.build_chunked_oci || self.rechunk {
198 DriverArgs::builder()
199 .build_driver(BuildDriverType::Podman)
200 .run_driver(RunDriverType::Podman)
201 .maybe_boot_driver(self.drivers.boot_driver)
202 .maybe_signing_driver(self.drivers.signing_driver)
203 .build()
204 } else {
205 self.drivers
206 });
207
208 Credentials::init(self.credentials.clone());
209
210 if self.push && self.archive.is_some() {
211 bail!("You cannot use '--archive' and '--push' at the same time");
212 }
213
214 if self.rechunk && self.build_chunked_oci {
215 bail!("You cannot use '--rechunk' and '--build-chunked-oci' at the same time");
216 }
217
218 if self.push && !self.no_sign {
219 blue_build_utils::check_command_exists("cosign")?;
220 Driver::check_signing_files(CheckKeyPairOpts::builder().dir(Path::new(".")).build())?;
221 }
222
223 let tempdir = if let Some(ref dir) = self.tempdir {
224 TempDir::new_in(dir).into_diagnostic()?
225 } else {
226 TempDir::new().into_diagnostic()?
227 };
228 let recipe_paths = self.recipe.clone().map_or_else(|| {
229 let legacy_path = Path::new(CONFIG_PATH);
230 let recipe_path = Path::new(RECIPE_PATH);
231 if recipe_path.exists() && recipe_path.is_dir() {
232 vec![recipe_path.join(RECIPE_FILE)]
233 } else {
234 warn!("Use of {CONFIG_PATH} for recipes is deprecated, please move your recipe files into {RECIPE_PATH}");
235 vec![legacy_path.join(RECIPE_FILE)]
236 }
237 },
238 |recipes| {
239 let mut same = std::collections::HashSet::new();
240
241 recipes.into_iter().filter(|recipe| same.insert(recipe.clone())).collect()
242 });
243 recipe_paths.par_iter().try_for_each(|recipe| {
244 GenerateCommand::builder()
245 .output(
246 tempdir
247 .path()
248 .join(blue_build_utils::generate_containerfile_path(recipe)?),
249 )
250 .skip_validation(self.skip_validation)
251 .maybe_platform(self.platform.first().copied())
252 .recipe(recipe)
253 .drivers(self.drivers)
254 .build()
255 .try_run()
256 })?;
257
258 self.start(&recipe_paths, tempdir.path())
259 }
260}
261
262impl BuildCommand {
263 fn start(&self, recipe_paths: &[PathBuf], temp_dir: &Path) -> Result<()> {
264 trace!(
265 "BuildCommand::start({recipe_paths:?}, {})",
266 temp_dir.display()
267 );
268
269 let images = recipe_paths
270 .par_iter()
271 .try_fold(Vec::new, |mut images, recipe_path| -> Result<Vec<String>> {
272 images.extend(self.build(
273 recipe_path,
274 &temp_dir.join(blue_build_utils::generate_containerfile_path(recipe_path)?),
275 )?);
276 Ok(images)
277 })
278 .try_reduce(Vec::new, |mut init, image_names| {
279 let color = gen_random_ansi_color();
280 init.extend(image_names.iter().map(|image| color_str(image, color)));
281 Ok(init)
282 })?;
283
284 info!(
285 "Finished building:\n{}",
286 images
287 .iter()
288 .map(|image| format!("\t- {image}"))
289 .collect::<Vec<_>>()
290 .join("\n")
291 );
292 Ok(())
293 }
294
295 #[expect(clippy::too_many_lines)]
296 fn build(&self, recipe_path: &Path, containerfile: &Path) -> Result<Vec<String>> {
297 trace!(
298 "BuildCommand::build({}, {})",
299 recipe_path.display(),
300 containerfile.display()
301 );
302
303 let recipe = &Recipe::parse(recipe_path)?;
304 let tags = &Driver::generate_tags(
305 GenerateTagsOpts::builder()
306 .oci_ref(&recipe.base_image_ref()?)
307 .maybe_alt_tags(recipe.alt_tags.as_deref())
308 .maybe_platform(self.platform.first().copied())
309 .build(),
310 )?;
311 assert!(
312 tags.is_empty().not(),
313 "At least 1 tag must have been generated"
314 );
315
316 let image = &Driver::generate_image_name(
317 GenerateImageNameOpts::builder()
318 .name(recipe.name.trim())
319 .maybe_registry(self.credentials.registry.as_deref())
320 .maybe_registry_namespace(self.registry_namespace.as_deref())
321 .maybe_tag(tags.first())
322 .build(),
323 )?;
324
325 if self.push {
326 Driver::login(image.registry())?;
327 Driver::signing_login(image.registry())?;
328 }
329
330 let cache_image = (self.cache_layers && self.push).then(|| {
331 let cache_image = Reference::with_tag(
332 image.registry().to_string(),
333 image.repository().to_string(),
334 format!(
335 "{}-cache",
336 image.tag().expect("Reference should be built with tag")
337 ),
338 );
339 debug!("Using {cache_image} for caching layers");
340 cache_image
341 });
342 let cache_image = cache_image.as_ref();
343
344 let platforms = match &recipe.platforms {
345 None if self.platform.is_empty() => &vec![Platform::default()],
346 Some(platform) if platform.is_empty().not() && self.platform.is_empty() => {
347 &platform.clone()
348 }
349 _ => &self.platform.clone(),
350 };
351 assert!(
352 platforms.is_empty().not(),
353 "At least one platform must be built"
354 );
355
356 let secrets = &recipe.get_secrets();
357
358 let image_ref = self.archive.as_ref().map_or_else(
359 || ImageRef::from(image),
360 |archive_dir| {
361 ImageRef::from(PathBuf::from(format!(
362 "{}/{}.{ARCHIVE_SUFFIX}",
363 archive_dir.to_string_lossy().trim_end_matches('/'),
364 recipe.name.to_lowercase().replace('/', "_"),
365 )))
366 },
367 );
368
369 let build_tag_opts = BuildTagPushOpts::builder()
370 .image(&image_ref)
371 .containerfile(containerfile)
372 .platform(platforms)
373 .squash(self.squash)
374 .maybe_cache_from(cache_image)
375 .maybe_cache_to(cache_image)
376 .secrets(secrets);
377
378 let opts = if matches!(image_ref, ImageRef::Remote(_)) {
379 build_tag_opts
380 .tags(tags)
381 .push(self.push)
382 .retry_push(self.retry_push)
383 .retry_count(self.retry_count)
384 .compression(self.compression_format)
385 .build()
386 } else {
387 build_tag_opts.build()
388 };
389
390 let images = if self.build_chunked_oci {
391 let base_image = recipe.base_image_ref()?;
392 let base_digest =
393 Driver::get_metadata(GetMetadataOpts::builder().image(&base_image).build())?
394 .digest()
395 .to_owned();
396 let remove_base_image = self
397 .remove_base_image
398 .then_some(base_image.clone_with_digest(base_digest));
399
400 let rechunk_opts = BuildChunkedOciOpts::builder()
401 .max_layers(self.max_layers)
402 .clear_plan(self.rechunk_clear_plan)
403 .build();
404 Driver::build_rechunk_tag_push(
405 BuildRechunkTagPushOpts::builder()
406 .build_tag_push_opts(opts)
407 .rechunk_opts(rechunk_opts)
408 .maybe_remove_base_image(remove_base_image.as_ref())
409 .build(),
410 )?
411 } else if self.rechunk {
412 self.rechunk(containerfile, recipe, tags, image, cache_image, platforms)?
413 } else {
414 Driver::build_tag_push(opts)?
415 };
416
417 if self.push && !self.no_sign {
418 Driver::sign_and_verify(
419 SignVerifyOpts::builder()
420 .image(image)
421 .retry_push(self.retry_push)
422 .retry_count(self.retry_count)
423 .platforms(platforms)
424 .build(),
425 )?;
426 }
427
428 Ok(images)
429 }
430
431 fn rechunk(
432 &self,
433 containerfile: &Path,
434 recipe: &Recipe,
435 tags: &[Tag],
436 image_name: &Reference,
437 cache_image: Option<&Reference>,
438 platforms: &[Platform],
439 ) -> Result<Vec<String>, miette::Error> {
440 trace!(
441 "BuildCommand::rechunk({}, {recipe:?}, {tags:?}, {image_name}, {cache_image:?}, {platforms:?})",
442 containerfile.display()
443 );
444
445 let base_image = recipe.base_image_ref()?;
446 let base_digest =
447 &Driver::get_metadata(GetMetadataOpts::builder().image(&base_image).build())?;
448 let base_digest = base_digest.digest();
449
450 let default_labels = generate_default_labels(recipe)?;
451 let labels = recipe.generate_labels(&default_labels);
452
453 Driver::rechunk(
454 RechunkOpts::builder()
455 .image(image_name)
456 .containerfile(containerfile)
457 .platform(platforms)
458 .tags(tags)
459 .push(self.push)
460 .version(&format!(
461 "{version}.<date>",
462 version = Driver::get_os_version()
463 .oci_ref(&recipe.base_image_ref()?)
464 .call()?,
465 ))
466 .retry_push(self.retry_push)
467 .retry_count(self.retry_count)
468 .compression(self.compression_format)
469 .base_digest(base_digest)
470 .repo(&Driver::get_repo_url()?)
471 .name(&recipe.name)
472 .description(&recipe.description)
473 .base_image(&base_image)
474 .maybe_tempdir(self.tempdir.as_deref())
475 .clear_plan(self.rechunk_clear_plan)
476 .maybe_cache_from(cache_image)
477 .maybe_cache_to(cache_image)
478 .secrets(&recipe.get_secrets())
479 .labels(&labels)
480 .build(),
481 )
482 }
483}