From 942dfa2532a00f8b5afe1cee9600dfa8ee824f3b Mon Sep 17 00:00:00 2001 From: Gerald Pinder Date: Tue, 16 Jun 2026 01:41:51 -0400 Subject: [PATCH] feat(recipe-v2): Add recipe upgrade command --- Cargo.toml | 2 +- process/drivers/opts/rechunk.rs | 2 +- recipe/src/lib.rs | 29 +++++-- recipe/src/recipe_v1.rs | 10 +-- recipe/src/recipe_v2.rs | 141 ++++++++++++++++++++++++++------ src/bin/bluebuild.rs | 3 + src/commands.rs | 6 ++ src/commands/build.rs | 2 +- src/commands/generate.rs | 15 ++-- src/commands/recipe.rs | 86 +++++++++++++++++++ utils/src/container.rs | 17 ++-- utils/src/env_str.rs | 31 ++----- 12 files changed, 269 insertions(+), 75 deletions(-) create mode 100644 src/commands/recipe.rs diff --git a/Cargo.toml b/Cargo.toml index 8ae7bbd6..520ee97d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,7 +113,7 @@ bon.workspace = true # Top level features default = [] -v0_10_0 = [ +v1_0_0 = [ "bootc", "recipe-v2" ] diff --git a/process/drivers/opts/rechunk.rs b/process/drivers/opts/rechunk.rs index 27cd2e41..72f290c1 100644 --- a/process/drivers/opts/rechunk.rs +++ b/process/drivers/opts/rechunk.rs @@ -20,7 +20,7 @@ pub struct RechunkOpts<'scope> { pub platform: &'scope [Platform], pub version: &'scope str, pub name: &'scope str, - pub description: &'scope str, + pub description: Option<&'scope str>, pub base_digest: &'scope str, pub base_image: &'scope Reference, pub repo: &'scope str, diff --git a/recipe/src/lib.rs b/recipe/src/lib.rs index 01ec6605..cbfda8d5 100644 --- a/recipe/src/lib.rs +++ b/recipe/src/lib.rs @@ -61,10 +61,10 @@ trait RecipeSetters: RecipeGetters { pub trait RecipeGetters { fn get_name(&self) -> &str; - fn get_description(&self) -> &str; + fn get_description(&self) -> Option<&str>; fn get_modules(&self) -> &[Module]; fn get_stages(&self) -> &[Stage]; - fn get_labels(&self) -> HashMap<&String, &String>; + fn get_labels(&self) -> HashMap<&str, &str>; fn get_alt_tags(&self) -> Option<&[Tag]>; fn get_platforms(&self) -> &[Platform]; fn get_base_image(&self) -> Cow<'_, str>; @@ -86,13 +86,13 @@ pub trait RecipeGetters { &self, default_labels: &BTreeMap, ) -> BTreeMap { - let mut labels = default_labels.iter().chain(self.get_labels()).fold( + let mut labels = default_labels.iter().map(|(key, value)| (&**key, &**value)).chain(self.get_labels()).fold( BTreeMap::new(), |mut acc, (k, v)| { if let Some(existing_value) = acc.get(k) { warn!("Found conflicting values for label: {k}, contains: {existing_value}, overwritten by: {v}"); } - acc.insert(k.clone(), v.clone()); + acc.insert(k.to_owned(), v.to_owned()); acc }, ); @@ -100,7 +100,7 @@ pub trait RecipeGetters { if !labels.contains_key("io.artifacthub.package.readme-url") { // adding this if not included in the custom labeling to maintain backwards compatibility since this was hardcoded into the old template labels.insert( - "io.artifacthub.package.readme-url".to_string(), + "io.artifacthub.package.readme-url".into(), "https://raw.githubusercontent.com/blue-build/cli/main/README.md".into(), ); } @@ -209,6 +209,21 @@ impl Recipe { inner(path.as_ref()) } + #[must_use] + #[cfg_attr(not(feature = "recipe-v2"), expect(clippy::missing_const_for_fn))] + pub fn upgrade(self) -> Self { + match self { + #[cfg(feature = "recipe-v2")] + Self::V1(recipe) => Self::V2(Box::new(RecipeV2::from(*recipe))), + + #[cfg(feature = "recipe-v2")] + recipe @ Self::V2(_) => recipe, + + #[cfg(not(feature = "recipe-v2"))] + recipe @ Self::V1(_) => recipe, + } + } + fn version_number(&self) -> Number { match self { Self::V1(_) => 1, @@ -275,7 +290,7 @@ impl RecipeGetters for Recipe { impl_recipe!(self, get_name()) } - fn get_description(&self) -> &str { + fn get_description(&self) -> Option<&str> { impl_recipe!(self, get_description()) } @@ -287,7 +302,7 @@ impl RecipeGetters for Recipe { impl_recipe!(self, get_stages()) } - fn get_labels(&self) -> HashMap<&String, &String> { + fn get_labels(&self) -> HashMap<&str, &str> { impl_recipe!(self, get_labels()) } diff --git a/recipe/src/recipe_v1.rs b/recipe/src/recipe_v1.rs index 96447ccc..20057ad3 100644 --- a/recipe/src/recipe_v1.rs +++ b/recipe/src/recipe_v1.rs @@ -102,12 +102,12 @@ impl RecipeGetters for RecipeV1 { &self.name } - fn get_description(&self) -> &str { - &self.description + fn get_description(&self) -> Option<&str> { + Some(&self.description) } fn get_base_image(&self) -> Cow<'_, str> { - Cow::Borrowed(&**self.base_image) + Cow::Borrowed(&self.base_image) } fn base_image_ref(&self) -> Result { @@ -118,11 +118,11 @@ impl RecipeGetters for RecipeV1 { .with_context(|| format!("Unable to parse base image {base_image}")) } - fn get_labels(&self) -> HashMap<&String, &String> { + fn get_labels(&self) -> HashMap<&str, &str> { self.labels .iter() .flatten() - .map(|(key, value)| (key, &**value)) + .map(|(key, value)| (&**key, &**value)) .collect() } diff --git a/recipe/src/recipe_v2.rs b/recipe/src/recipe_v2.rs index dd4f9561..d76269c6 100644 --- a/recipe/src/recipe_v2.rs +++ b/recipe/src/recipe_v2.rs @@ -1,17 +1,50 @@ -use std::{borrow::Cow, collections::HashMap}; +use std::{borrow::Cow, collections::HashMap, ops::Deref}; use blue_build_utils::{ constants::BLUE_BUILD_DEFAULT_IMAGE, container::Tag, env_str::EnvString, platform::Platform, }; use bon::Builder; +use miette::{Context, IntoDiagnostic}; use oci_client::Reference; use serde::{Deserialize, Serialize}; use structstruck::strike; -use crate::{Module, RecipeGetters, RecipeSetters, Stage}; +use crate::{Module, RecipeGetters, RecipeSetters, RecipeV1, Stage}; use super::{MaybeVersion, ModuleExt, StagesExt}; +#[derive(Debug, Clone, Serialize)] +pub struct RecipeV2BaseImageStr(EnvString); + +impl From for RecipeV2BaseImageStr { + fn from(value: Reference) -> Self { + Self(EnvString::from(value.to_string())) + } +} + +impl<'de> Deserialize<'de> for RecipeV2BaseImageStr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let image = EnvString::deserialize(deserializer)?; + + if let Err(e) = image.parse::() { + return Err(serde::de::Error::custom(e)); + } + + Ok(Self(image)) + } +} + +impl Deref for RecipeV2BaseImageStr { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + strike! { /// The build recipe. /// @@ -35,7 +68,7 @@ strike! { #![serde(untagged)] /// String representation of an image ref. - Str(Reference), + Str(RecipeV2BaseImageStr), /// Object representation of an image ref. Obj { @@ -69,6 +102,7 @@ strike! { /// This will validate the image before building with it. /// /// URLs are supported. Paths are relative to the root of the project. + #[serde(default, skip_serializing_if = "Option::is_none")] pub public_key: Option, }, @@ -78,7 +112,13 @@ strike! { pub name: EnvString, /// The image description. Published to GHCR in the image metadata. - pub description: EnvString, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Allows setting custom tags on the recipe’s final image. + /// Adding tags to this property will override the `latest` and timestamp tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, /// A collection of custom labels that will be applied to the image. /// @@ -90,11 +130,6 @@ strike! { /// Specifications for the image that modifies how it is built and published. #[serde(default, skip_serializing_if = "Option::is_none")] pub spec: Option, - /// Specify a list of the platforms to build for your image. /// The resulting images will be added to a manifest list that /// allows your host’s container runtime to pull the correct @@ -159,13 +194,63 @@ strike! { } } +impl From for RecipeV2 { + fn from(value: RecipeV1) -> Self { + Self { + base: RecipeV2Base { + image: RecipeV2BaseImage::Str(RecipeV2BaseImageStr(EnvString::from(format!( + "{}:{}", + value.base_image.unexpanded(), + value.image_version.unexpanded(), + )))), + public_key: None, + }, + metadata: RecipeV2Metadata { + name: value.name, + description: Some(value.description), + tags: value.alt_tags.unwrap_or_default(), + labels: value.labels, + }, + spec: { + let has_versions = value.blue_build_tag.is_some() + || value.cosign_version.is_some() + || value.nushell_version.is_some(); + let tool_versions = has_versions.then_some(RecipeV2SpecToolVersions { + bluebuild: value.blue_build_tag, + nushell: match value.nushell_version { + None | Some(MaybeVersion::None) => None, + Some(MaybeVersion::VersionOrBranch(tag)) => Some(tag), + }, + cosign: match value.cosign_version { + None | Some(MaybeVersion::None) => None, + Some(MaybeVersion::VersionOrBranch(tag)) => Some(tag), + }, + }); + match (value.platforms, has_versions) { + (None, false) => None, + (Some(platforms), false) => Some(RecipeV2Spec { + platforms, + tool_versions: None, + }), + (Some(platforms), true) => Some(RecipeV2Spec { + platforms, + tool_versions, + }), + (None, true) => Some(RecipeV2Spec { + platforms: Vec::default(), + tool_versions, + }), + } + }, + stages_ext: value.stages_ext, + modules_ext: value.modules_ext, + } + } +} + impl Default for RecipeV2BaseImage { fn default() -> Self { - Self::Str( - BLUE_BUILD_DEFAULT_IMAGE - .try_into() - .expect("Should be a valid image ref"), - ) + Self::Str(RecipeV2BaseImageStr(BLUE_BUILD_DEFAULT_IMAGE.into())) } } @@ -182,22 +267,21 @@ impl RecipeGetters for RecipeV2 { &self.metadata.name } - fn get_description(&self) -> &str { - &self.metadata.description + fn get_description(&self) -> Option<&str> { + self.metadata.description.as_deref() } - fn get_labels(&self) -> HashMap<&String, &String> { + fn get_labels(&self) -> HashMap<&str, &str> { self.metadata .labels .iter() .flatten() - .map(|(key, value)| (key, &**value)) + .map(|(key, value)| (&**key, &**value)) .collect() } fn get_alt_tags(&self) -> Option<&[Tag]> { - let spec = self.spec.as_ref()?; - match &spec.tags[..] { + match &self.metadata.tags[..] { [] => None, tags => Some(tags), } @@ -209,11 +293,13 @@ impl RecipeGetters for RecipeV2 { fn get_base_image(&self) -> Cow<'_, str> { match &self.base.image { - RecipeV2BaseImage::Str(image) => Cow::Owned(format!( - "{}/{}", - image.resolve_registry(), - image.repository() - )), + RecipeV2BaseImage::Str(image) => Cow::Borrowed( + image + .split_once(':') // Split at tag start + .or_else(|| image.split_once('@')) // or digest start + .unwrap_or((image, "")) // or the image without a tag + .0, + ), RecipeV2BaseImage::Obj { registry, repository, @@ -256,7 +342,10 @@ impl RecipeGetters for RecipeV2 { fn base_image_ref(&self) -> miette::Result { Ok(match &self.base.image { - RecipeV2BaseImage::Str(image) => image.clone(), + RecipeV2BaseImage::Str(RecipeV2BaseImageStr(image)) => image + .parse() + .into_diagnostic() + .wrap_err_with(|| format!("Failed to parse base image ref {image}"))?, RecipeV2BaseImage::Obj { registry, repository, diff --git a/src/bin/bluebuild.rs b/src/bin/bluebuild.rs index bbcd07d8..7b46b7a3 100644 --- a/src/bin/bluebuild.rs +++ b/src/bin/bluebuild.rs @@ -58,5 +58,8 @@ fn main() { CommandArgs::Prune(mut command) => command.run(), CommandArgs::BugReport(mut command) => command.run(), CommandArgs::Completions(mut command) => command.run(), + + #[cfg(feature = "recipe-v2")] + CommandArgs::Recipe(mut command) => command.run(), }); } diff --git a/src/commands.rs b/src/commands.rs index 3564703a..05ee59d6 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,6 +16,8 @@ pub mod generate_iso; pub mod init; pub mod login; pub mod prune; +#[cfg(feature = "recipe-v2")] +pub mod recipe; pub mod switch; pub mod validate; @@ -114,6 +116,10 @@ pub enum CommandArgs { /// Generate shell completions for your shell to stdout Completions(completions::CompletionsCommand), + + /// Perform operations on recipe files + #[cfg(feature = "recipe-v2")] + Recipe(recipe::RecipeCommand), } #[cfg(test)] diff --git a/src/commands/build.rs b/src/commands/build.rs index 14750fe8..3eca5fbc 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -517,7 +517,7 @@ impl BuildCommand { .base_digest(base_digest) .repo(&Driver::get_repo_url()?) .name(recipe.get_name()) - .description(recipe.get_description()) + .maybe_description(recipe.get_description()) .base_image(&base_image) .maybe_tempdir(self.tempdir.as_deref()) .clear_plan(self.rechunk_clear_plan) diff --git a/src/commands/generate.rs b/src/commands/generate.rs index 89f15920..962dc0cb 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -224,7 +224,7 @@ pub fn generate_default_labels(recipe: &Recipe) -> Result Result Result, + }, +} + +impl BlueBuildCommand for RecipeCommand { + fn try_run(&mut self) -> miette::Result<()> { + match &self.subcommand { + RecipeSubCommand::Upgrade { paths } => { + for path in paths { + debug!("Opening file {} for reading", path.display()); + let file = OpenOptions::new() + .read(true) + .open(path) + .into_diagnostic() + .wrap_err_with(|| { + format!("Failed to open {} for reading", path.display()) + })?; + + debug!("Deserializing file {}", path.display()); + let recipe: Recipe = serde_yaml::from_reader(&file) + .into_diagnostic() + .wrap_err_with(|| { + format!("Failed to deserialize recipe file {}", path.display()) + })?; + drop(file); + + debug!("Upgrading recipe"); + let recipe = recipe.upgrade(); + + debug!("Opening file {} for writing", path.display()); + let file = &mut OpenOptions::new() + .write(true) + .truncate(true) + .open(path) + .into_diagnostic() + .wrap_err_with(|| { + format!("Failed to open {} for writing", path.display()) + })?; + + debug!("Writing schema header to file {}", path.display()); + writeln!( + file, + concat!( + "---\n", + "# yaml-language-server: ", + "$schema=https://schema.blue-build.org/recipe.json", + ), + ) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to write recipe file {}", path.display()))?; + + debug!("Writing recipe to file {}", path.display()); + serde_yaml::to_writer(file, &recipe) + .into_diagnostic() + .wrap_err_with(|| { + format!("Failed to write recipe file {}", path.display()) + })?; + } + } + } + Ok(()) + } +} diff --git a/utils/src/container.rs b/utils/src/container.rs index 4075a2a3..24199ffe 100644 --- a/utils/src/container.rs +++ b/utils/src/container.rs @@ -282,6 +282,11 @@ impl Tag { pub fn as_str(&self) -> &str { &self.0 } + + #[must_use] + pub fn unexpanded(&self) -> &str { + self.0.unexpanded() + } } fn eval_tag(haystack: &EnvString) -> bool { @@ -323,16 +328,16 @@ impl<'de> Deserialize<'de> for Tag { { let value = Value::deserialize(deserializer)?; - let expanded = match value { + let expanded = EnvString::from(match value { Value::Number(num) => num.to_string(), Value::String(string) => string, _ => return Err(serde::de::Error::custom("Value was not a string or number")), + }); + if eval_tag(&expanded) { + Ok(Self(expanded)) + } else { + Err(serde::de::Error::custom(format!("Invalid tag: {expanded}"))) } - .parse() - .map_err(serde::de::Error::custom)?; - eval_tag(&expanded) - .then(|| Self(expanded.clone())) - .ok_or_else(|| serde::de::Error::custom(format!("Invalid tag: {expanded}"))) } } diff --git a/utils/src/env_str.rs b/utils/src/env_str.rs index d2b877c4..944d09d6 100644 --- a/utils/src/env_str.rs +++ b/utils/src/env_str.rs @@ -1,6 +1,3 @@ -use std::str::FromStr; - -use miette::{Context, IntoDiagnostic}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Eq)] @@ -9,13 +6,19 @@ pub struct EnvString { expanded: String, } +impl EnvString { + #[must_use] + pub fn unexpanded(&self) -> &str { + &self.unexpanded + } +} + impl<'de> Deserialize<'de> for EnvString { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - let unexpanded = String::deserialize(deserializer)?; - unexpanded.parse().map_err(serde::de::Error::custom) + String::deserialize(deserializer).map(Self::from) } } @@ -28,22 +31,6 @@ impl Serialize for EnvString { } } -impl FromStr for EnvString { - type Err = miette::Report; - - fn from_str(s: &str) -> Result { - let expanded = shellexpand::env(s) - .into_diagnostic() - .wrap_err_with(|| format!("Unable to expand environment variables in string: {s}"))? - .into(); - - Ok(Self { - unexpanded: s.into(), - expanded, - }) - } -} - impl std::fmt::Display for EnvString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.expanded) @@ -51,7 +38,7 @@ impl std::fmt::Display for EnvString { } impl std::ops::Deref for EnvString { - type Target = String; + type Target = str; fn deref(&self) -> &Self::Target { &self.expanded