Skip to main content

blue_build/commands/
generate.rs

1use std::{
2    collections::BTreeMap,
3    ops::Not,
4    path::{Path, PathBuf},
5};
6
7use crate::{BuildScripts, DriverTemplate, commands::validate::ValidateCommand};
8use blue_build_process_management::drivers::{
9    CiDriver, Driver, DriverArgs, InspectDriver, opts::GetMetadataOpts,
10};
11use blue_build_recipe::Recipe;
12use blue_build_template::{ContainerFileTemplate, Template};
13use blue_build_utils::{
14    constants::{BB_SKIP_VALIDATION, CONFIG_PATH, RECIPE_FILE, RECIPE_PATH},
15    current_timestamp,
16    platform::Platform,
17    syntax_highlighting::{self, DefaultThemes},
18};
19use bon::Builder;
20use cached::proc_macro::cached;
21use clap::Args;
22use colored::Colorize;
23use log::{debug, info, trace, warn};
24use miette::{Context, IntoDiagnostic, Result};
25use oci_client::Reference;
26
27use super::BlueBuildCommand;
28
29#[derive(Debug, Clone, Args, Builder)]
30pub struct GenerateCommand {
31    /// The recipe file to create a template from
32    #[arg()]
33    #[builder(into)]
34    recipe: Option<PathBuf>,
35
36    /// File to output to instead of STDOUT
37    #[arg(short, long)]
38    #[builder(into)]
39    output: Option<PathBuf>,
40
41    /// The registry domain the image will be published to.
42    ///
43    /// This is used for modules that need to know where
44    /// the image is being published (i.e. the signing module).
45    #[arg(long)]
46    #[builder(into)]
47    registry: Option<String>,
48
49    /// The registry namespace the image will be published to.
50    ///
51    /// This is used for modules that need to know where
52    /// the image is being published (i.e. the signing module).
53    #[arg(long)]
54    #[builder(into)]
55    registry_namespace: Option<String>,
56
57    /// Instead of creating a Containerfile, display
58    /// the full recipe after traversing all `from-file` properties.
59    ///
60    /// This can be used to help debug the order
61    /// you defined your recipe.
62    #[arg(short, long)]
63    #[builder(default)]
64    display_full_recipe: bool,
65
66    /// Choose a theme for the syntax highlighting
67    /// for the Containerfile or Yaml.
68    ///
69    /// The default is `mocha-dark`.
70    #[arg(short = 't', long)]
71    syntax_theme: Option<DefaultThemes>,
72
73    /// Inspect the image for a specific platform
74    /// when retrieving the version.
75    #[arg(long)]
76    platform: Option<Platform>,
77
78    /// Skips validation of the recipe file.
79    #[arg(long, env = BB_SKIP_VALIDATION)]
80    #[builder(default)]
81    skip_validation: bool,
82
83    #[clap(flatten)]
84    #[builder(default)]
85    drivers: DriverArgs,
86}
87
88impl BlueBuildCommand for GenerateCommand {
89    fn try_run(&mut self) -> Result<()> {
90        Driver::init(self.drivers);
91
92        self.template_file()
93    }
94}
95
96impl GenerateCommand {
97    fn template_file(&self) -> Result<()> {
98        trace!("TemplateCommand::template_file()");
99
100        let recipe_path = self.recipe.clone().unwrap_or_else(|| {
101            let legacy_path = Path::new(CONFIG_PATH);
102            let recipe_path = Path::new(RECIPE_PATH);
103            if recipe_path.exists() && recipe_path.is_dir() {
104                recipe_path.join(RECIPE_FILE)
105            } else {
106                warn!("Use of {CONFIG_PATH} for recipes is deprecated, please move your recipe files into {RECIPE_PATH}");
107                legacy_path.join(RECIPE_FILE)
108            }
109        });
110
111        if self.skip_validation.not() {
112            ValidateCommand::builder()
113                .recipe(recipe_path.clone())
114                .build()
115                .try_run()?;
116        }
117
118        let registry = if let (Some(registry), Some(registry_namespace)) =
119            (&self.registry, &self.registry_namespace)
120        {
121            format!("{registry}/{registry_namespace}")
122        } else {
123            Driver::get_registry()?
124        };
125
126        debug!("Deserializing recipe");
127        let recipe = Recipe::parse(&recipe_path)?;
128        trace!("recipe_de: {recipe:#?}");
129
130        if self.display_full_recipe {
131            if let Some(output) = self.output.as_ref() {
132                std::fs::write(output, serde_yaml::to_string(&recipe).into_diagnostic()?)
133                    .into_diagnostic()?;
134            } else {
135                syntax_highlighting::print_ser(&recipe, "yml", self.syntax_theme)?;
136            }
137            return Ok(());
138        }
139
140        info!("Templating for recipe at {}", recipe_path.display());
141
142        let base_image: Reference = format!("{}:{}", &recipe.base_image, &recipe.image_version)
143            .parse()
144            .into_diagnostic()
145            .wrap_err_with(|| {
146                format!(
147                    "Failed to parse image with base {} and version {}",
148                    recipe.base_image.bright_blue(),
149                    recipe.image_version.to_string().bright_yellow()
150                )
151            })?;
152        let base_digest =
153            &Driver::get_metadata(GetMetadataOpts::builder().image(&base_image).build())?;
154        let base_digest = base_digest.digest();
155        let build_features = &[
156            #[cfg(feature = "bootc")]
157            "bootc".into(),
158        ];
159        let build_scripts_dir = BuildScripts::extract_mount_dir()?;
160
161        let default_labels = generate_default_labels(&recipe)?;
162        let labels = recipe.generate_labels(&default_labels);
163
164        let template = ContainerFileTemplate::builder()
165            .os_version(
166                Driver::get_os_version()
167                    .oci_ref(&recipe.base_image_ref()?)
168                    .call()?,
169            )
170            .build_id(Driver::get_build_id())
171            .recipe(&recipe)
172            .recipe_path(recipe_path.as_path())
173            .registry(&registry)
174            .build_scripts_dir(&build_scripts_dir)
175            .base_digest(base_digest)
176            .maybe_nushell_version(recipe.nushell_version.as_ref())
177            .build_features(build_features)
178            .build_engine(Driver::get_build_driver().build_engine())
179            .labels(&labels)
180            .build();
181
182        let output_str = template.render().into_diagnostic().wrap_err_with(|| {
183            format!(
184                "Failed to render Containerfile for {}",
185                recipe_path.display().to_string().cyan()
186            )
187        })?;
188        if let Some(output) = self.output.as_ref() {
189            debug!("Templating to file {}", output.display());
190            trace!("Containerfile:\n{output_str}");
191
192            std::fs::write(output, output_str).into_diagnostic()?;
193        } else {
194            debug!("Templating to stdout");
195            syntax_highlighting::print(&output_str, "Dockerfile", self.syntax_theme)?;
196        }
197
198        Ok(())
199    }
200}
201
202/// Function will generate the labels for an image
203/// during generation of the containerfile and
204/// after the optional rechunking of an image.
205///
206/// It is cached to avoid recalculating the labels
207/// in the case they must be re-applied after rechunking.
208///
209/// # Arguments
210///
211/// * `recipe_path`: path to a given recipe
212///
213/// Returns: Result<String, Report>
214///
215/// # Errors
216///
217/// Returns an error if:
218/// - The recipe file cannot be parsed
219/// - Unable to retrieve repository URL
220/// - Unable to get metadata for the base image
221/// - Unable to generate the base image reference
222pub fn generate_default_labels(recipe: &Recipe) -> Result<BTreeMap<String, String>> {
223    // Use an inner cached function to hide clippy documentation errors
224    #[cached(
225        result = true,
226        key = "String",
227        convert = r"{ recipe.name.to_string() }"
228    )]
229    fn inner(recipe: &Recipe) -> Result<BTreeMap<String, String>> {
230        trace!("Generate LABELS for recipe: ({})", recipe.name);
231
232        let build_id = Driver::get_build_id().to_string();
233        let source = Driver::get_repo_url()?;
234        let image_metada = Driver::get_metadata(
235            GetMetadataOpts::builder()
236                .image(&recipe.base_image_ref()?)
237                .build(),
238        )?;
239        let base_digest = image_metada.digest().to_string();
240        let base_name = format!("{}:{}", recipe.base_image, recipe.image_version);
241        let current_timestamp = current_timestamp();
242
243        // use btree here to have nice sorting by key,
244        // makes it easier to read and analyze resulting labels
245        Ok(BTreeMap::from([
246            (
247                blue_build_utils::constants::BUILD_ID_LABEL.to_string(),
248                build_id,
249            ),
250            (
251                "org.opencontainers.image.title".to_string(),
252                recipe.name.clone(),
253            ),
254            (
255                "org.opencontainers.image.description".to_string(),
256                recipe.description.clone(),
257            ),
258            ("org.opencontainers.image.source".to_string(), source),
259            (
260                "org.opencontainers.image.base.digest".to_string(),
261                base_digest,
262            ),
263            ("org.opencontainers.image.base.name".to_string(), base_name),
264            (
265                "org.opencontainers.image.created".to_string(),
266                current_timestamp,
267            ),
268        ]))
269    }
270    inner(recipe)
271}