blue_build/commands/
build.rs

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    /// The recipe file to build an image
41    #[arg()]
42    #[builder(into)]
43    recipe: Option<Vec<PathBuf>>,
44
45    /// Push the image with all the tags.
46    ///
47    /// Requires `--registry`,
48    /// `--username`, and `--password` if not
49    /// building in CI.
50    #[arg(short, long, group = "archive_push")]
51    #[builder(default)]
52    push: bool,
53
54    /// Build for a specific platform.
55    ///
56    /// NOTE: Building for a different architecture
57    /// than your hardware will require installing
58    /// qemu. Build times will be much greater when
59    /// building for a non-native architecture.
60    #[arg(long)]
61    platform: Option<Platform>,
62
63    /// The compression format the images
64    /// will be pushed in.
65    #[arg(short, long, default_value_t = CompressionType::Gzip)]
66    #[builder(default)]
67    compression_format: CompressionType,
68
69    /// Enable retrying to push the image.
70    #[arg(short, long)]
71    #[builder(default)]
72    retry_push: bool,
73
74    /// The number of times to retry pushing the image.
75    #[arg(long, default_value_t = 1)]
76    #[builder(default)]
77    retry_count: u8,
78
79    /// Archives the built image into a tarfile
80    /// in the specified directory.
81    #[arg(short, long, group = "archive_rechunk", group = "archive_push")]
82    #[builder(into)]
83    archive: Option<PathBuf>,
84
85    /// The url path to your base
86    /// project images.
87    #[arg(long, env = BB_REGISTRY_NAMESPACE, visible_alias("registry-path"))]
88    #[builder(into)]
89    registry_namespace: Option<String>,
90
91    /// Do not sign the image on push.
92    #[arg(long)]
93    #[builder(default)]
94    no_sign: bool,
95
96    /// Runs all instructions inside one layer of the final image.
97    ///
98    /// WARN: This doesn't work with the
99    /// docker driver as it has been deprecated.
100    ///
101    /// NOTE: Squash has a performance benefit for
102    /// podman and buildah when running inside a container.
103    #[arg(short, long)]
104    #[builder(default)]
105    squash: bool,
106
107    /// Performs rechunking on the image to allow for smaller images
108    /// and smaller updates.
109    ///
110    /// WARN: This will increase the build-time
111    /// and take up more space during build-time.
112    ///
113    /// NOTE: This must be run as root!
114    #[arg(long, group = "archive_rechunk", env = blue_build_utils::constants::BB_BUILD_RECHUNK)]
115    #[builder(default)]
116    rechunk: bool,
117
118    /// Use a fresh rechunk plan, regardless of previous ref.
119    ///
120    /// NOTE: Only works with `--rechunk`.
121    #[arg(long, env = blue_build_utils::constants::BB_BUILD_RECHUNK_CLEAR_PLAN)]
122    #[builder(default)]
123    rechunk_clear_plan: bool,
124
125    /// The location to temporarily store files
126    /// while building. If unset, it will use `/tmp`.
127    #[arg(long)]
128    tempdir: Option<PathBuf>,
129
130    /// Automatically cache build layers to the registry.
131    ///
132    /// NOTE: Only works when using --push
133    #[builder(default)]
134    #[arg(long, env = blue_build_utils::constants::BB_CACHE_LAYERS)]
135    cache_layers: bool,
136
137    /// Skips validation of the recipe file.
138    #[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    /// Runs the command and returns a result.
153    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}