Skip to main content

blue_build/commands/
build.rs

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    /// The recipe file to build an image
50    #[arg()]
51    #[builder(into)]
52    recipe: Option<Vec<PathBuf>>,
53
54    /// Push the image with all the tags.
55    ///
56    /// Requires `--registry`,
57    /// `--username`, and `--password` if not
58    /// building in CI.
59    #[arg(short, long, group = "archive_push", env = BB_BUILD_PUSH)]
60    #[builder(default)]
61    push: bool,
62
63    /// Build for specific platforms.
64    ///
65    /// This will override any platform setting in
66    /// the recipes you're building.
67    ///
68    /// NOTE: Building for a different architecture
69    /// than your hardware will require installing
70    /// qemu. Build times will be much greater when
71    /// building for a non-native architecture.
72    #[builder(default)]
73    #[arg(long, env = BB_BUILD_PLATFORM)]
74    platform: Vec<Platform>,
75
76    /// The compression format the images
77    /// will be pushed in.
78    #[arg(short, long, default_value_t = CompressionType::Gzip)]
79    #[builder(default)]
80    compression_format: CompressionType,
81
82    /// Enable retrying to push the image.
83    #[arg(short, long, env = BB_BUILD_RETRY_PUSH)]
84    #[builder(default)]
85    retry_push: bool,
86
87    /// The number of times to retry pushing the image.
88    #[arg(long, default_value_t = 1, env = BB_BUILD_RETRY_COUNT)]
89    #[builder(default)]
90    retry_count: u8,
91
92    /// Archives the built image into a tarfile
93    /// in the specified directory.
94    #[arg(short, long, group = "archive_rechunk", group = "archive_push", env = BB_BUILD_ARCHIVE)]
95    #[builder(into)]
96    archive: Option<PathBuf>,
97
98    /// The url path to your base
99    /// project images.
100    #[arg(long, env = BB_REGISTRY_NAMESPACE, visible_alias("registry-path"))]
101    #[builder(into)]
102    registry_namespace: Option<String>,
103
104    /// Do not sign the image on push.
105    #[arg(long, env = BB_BUILD_NO_SIGN)]
106    #[builder(default)]
107    no_sign: bool,
108
109    /// Runs all instructions inside one layer of the final image.
110    ///
111    /// WARN: This doesn't work with the
112    /// docker driver as it has been deprecated.
113    ///
114    /// NOTE: Squash has a performance benefit for
115    /// podman and buildah when running inside a container.
116    #[arg(short, long, env = BB_BUILD_SQUASH)]
117    #[builder(default)]
118    squash: bool,
119
120    /// Uses `rpm-ostree compose build-chunked-oci` to rechunk the image,
121    /// allowing for smaller images and smaller updates.
122    ///
123    /// WARN: This will increase the build-time
124    /// and take up more space during build-time.
125    #[arg(long, env = BB_BUILD_CHUNKED_OCI)]
126    #[builder(default)]
127    build_chunked_oci: bool,
128
129    /// Maximum number of layers to use when rechunking. Requires `--build-chunked-oci`.
130    #[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    /// Removes the base image from local storage and prunes unused podman containers
140    /// and volumes after the image is built, but before running build-chunked-oci.
141    /// This frees up additional disk space.
142    #[arg(long, env = BB_BUILD_REMOVE_BASE_IMAGE, requires = "build_chunked_oci")]
143    #[builder(default)]
144    remove_base_image: bool,
145
146    /// Uses `hhd-dev/rechunk` to rechunk the image, allowing for smaller images
147    /// and smaller updates.
148    ///
149    /// WARN: This will be deprecated in the future.
150    ///
151    /// WARN: This will increase the build-time
152    /// and take up more space during build-time.
153    ///
154    /// NOTE: This must be run as root!
155    #[arg(long, group = "archive_rechunk", env = BB_BUILD_RECHUNK)]
156    #[builder(default)]
157    rechunk: bool,
158
159    /// Use a fresh rechunk plan, regardless of previous ref.
160    ///
161    /// NOTE: Only works with `--build-chunked-oci` or `--rechunk`.
162    #[arg(long, env = BB_BUILD_RECHUNK_CLEAR_PLAN)]
163    #[builder(default)]
164    rechunk_clear_plan: bool,
165
166    /// The location to temporarily store files
167    /// while building. If unset, it will use `/tmp`.
168    #[arg(long, env = BB_TEMPDIR)]
169    tempdir: Option<PathBuf>,
170
171    /// Automatically cache build layers to the registry.
172    ///
173    /// NOTE: Only works when using --push
174    #[builder(default)]
175    #[arg(long, env = BB_CACHE_LAYERS)]
176    cache_layers: bool,
177
178    /// Skips validation of the recipe file.
179    #[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    /// Runs the command and returns a result.
194    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}