diff --git a/process/drivers/github_driver.rs b/process/drivers/github_driver.rs index 18e33d8b..f7324f9e 100644 --- a/process/drivers/github_driver.rs +++ b/process/drivers/github_driver.rs @@ -39,20 +39,36 @@ impl CiDriver for GithubDriver { } fn generate_tags(opts: GenerateTagsOpts) -> Result> { + use blue_build_utils::tagging::{TagMetadata, apply_tagging_policies, resolve_tag_template}; + + let metadata = TagMetadata { + tag: None, + os_version: opts.os_version, + timestamp: opts.timestamp, + short_sha: opts.short_sha, + }; + + // 1. Manual Tags (Verbatim + Template Resolution) + if let Some(tags) = opts.tags { + return tags + .iter() + .map(|t| resolve_tag_template(t, &metadata).parse()) + .collect::>>(); + } + + // 2. Tagging Policies (if provided) + if let (Some(alt_tags), Some(policies)) = (opts.alt_tags, opts.tagging) { + return apply_tagging_policies(alt_tags, policies, &metadata); + } + + // 3. Fallback (Legacy uBlue Logic) const PR_EVENT: &str = "pull_request"; - let timestamp = blue_build_utils::get_tag_timestamp(); - let os_version = Driver::get_os_version() - .oci_ref(opts.oci_ref) - .call() - .inspect(|v| trace!("os_version={v}"))?; let ref_name = get_env_var(GITHUB_REF_NAME) .inspect(|v| trace!("{GITHUB_REF_NAME}={v}"))? .replace('/', "_"); - let short_sha = { - let mut short_sha = get_env_var(GITHUB_SHA).inspect(|v| trace!("{GITHUB_SHA}={v}"))?; - short_sha.truncate(7); - short_sha - }; + let short_sha = opts.short_sha.unwrap_or_default(); + let os_version = opts.os_version; + let timestamp = opts.timestamp; let tags = match ( Self::on_default_branch(), @@ -63,7 +79,7 @@ impl CiDriver for GithubDriver { (true, None, _, _) => { string_vec![ "latest", - ×tamp, + timestamp, format!("{os_version}"), format!("{timestamp}-{os_version}"), format!("{short_sha}-{os_version}"), @@ -310,12 +326,49 @@ mod test { GenerateTagsOpts::builder() .oci_ref(&oci_ref) .maybe_alt_tags(alt_tags.as_deref()) + .os_version("41") + .timestamp(&*TIMESTAMP) + .short_sha(COMMIT_SHA) + .platform(Platform::LinuxAmd64) + .build(), + ) + .unwrap(); + tags.sort(); + + assert_eq!(tags, expected); + } + + #[test] + fn generate_tags_policy() { + use blue_build_utils::tagging::TaggingPolicy; + + setup_default_branch(); + let oci_ref: Reference = "ghcr.io/ublue-os/silverblue-main".parse().unwrap(); + let alt_tags = vec!["stable".parse::().unwrap()]; + let policies = vec![TaggingPolicy { + match_tag: "stable".to_string(), + tags: vec!["{tag}-{os_version}".to_string(), "{timestamp}".to_string()], + }]; + + let mut tags = GithubDriver::generate_tags( + GenerateTagsOpts::builder() + .oci_ref(&oci_ref) + .alt_tags(&alt_tags) + .tagging(&policies) + .os_version("41") + .timestamp("20240101") .platform(Platform::LinuxAmd64) .build(), ) .unwrap(); tags.sort(); + let mut expected = vec![ + "stable-41".parse::().unwrap(), + "20240101".parse::().unwrap(), + ]; + expected.sort(); + assert_eq!(tags, expected); } } diff --git a/process/drivers/gitlab_driver.rs b/process/drivers/gitlab_driver.rs index e589c4e5..5548a25d 100644 --- a/process/drivers/gitlab_driver.rs +++ b/process/drivers/gitlab_driver.rs @@ -48,11 +48,33 @@ impl CiDriver for GitlabDriver { } fn generate_tags(opts: GenerateTagsOpts) -> Result> { + use blue_build_utils::tagging::{TagMetadata, apply_tagging_policies, resolve_tag_template}; + + let metadata = TagMetadata { + tag: None, + os_version: opts.os_version, + timestamp: opts.timestamp, + short_sha: opts.short_sha, + }; + + // 1. Manual Tags (Verbatim + Template Resolution) + if let Some(tags) = opts.tags { + return tags + .iter() + .map(|t| resolve_tag_template(t, &metadata).parse()) + .collect::>>(); + } + + // 2. Tagging Policies (if provided) + if let (Some(alt_tags), Some(policies)) = (opts.alt_tags, opts.tagging) { + return apply_tagging_policies(alt_tags, policies, &metadata); + } + + // 3. Fallback (Legacy uBlue Logic) const MR_EVENT: &str = "merge_request_event"; - let os_version = Driver::get_os_version().oci_ref(opts.oci_ref).call()?; - let timestamp = blue_build_utils::get_tag_timestamp(); - let short_sha = - get_env_var(CI_COMMIT_SHORT_SHA).inspect(|v| trace!("{CI_COMMIT_SHORT_SHA}={v}"))?; + let os_version = opts.os_version; + let timestamp = opts.timestamp; + let short_sha = opts.short_sha.unwrap_or_default(); let ref_name = get_env_var(CI_COMMIT_REF_NAME) .inspect(|v| trace!("{CI_COMMIT_REF_NAME}={v}"))? .replace('/', "_"); @@ -66,7 +88,7 @@ impl CiDriver for GitlabDriver { (true, None, _, _) => { string_vec![ "latest", - ×tamp, + timestamp, format!("{os_version}"), format!("{timestamp}-{os_version}"), format!("{short_sha}-{os_version}"), @@ -315,6 +337,9 @@ mod test { GenerateTagsOpts::builder() .oci_ref(&oci_ref) .maybe_alt_tags(alt_tags.as_deref()) + .os_version("41") + .timestamp(&*TIMESTAMP) + .short_sha(COMMIT_SHA) .platform(Platform::LinuxAmd64) .build(), ) diff --git a/process/drivers/local_driver.rs b/process/drivers/local_driver.rs index c92a5ab4..fc691307 100644 --- a/process/drivers/local_driver.rs +++ b/process/drivers/local_driver.rs @@ -24,10 +24,32 @@ impl CiDriver for LocalDriver { } fn generate_tags(opts: GenerateTagsOpts) -> Result> { + use blue_build_utils::tagging::{TagMetadata, apply_tagging_policies, resolve_tag_template}; + + let short_sha = opts.short_sha.map(|s| s.to_string()).or_else(commit_sha); + let metadata = TagMetadata { + tag: None, + os_version: opts.os_version, + timestamp: opts.timestamp, + short_sha: short_sha.as_deref(), + }; + + // 1. Manual Tags (Verbatim + Template Resolution) + if let Some(tags) = opts.tags { + return tags + .iter() + .map(|t| resolve_tag_template(t, &metadata).parse()) + .collect::>>(); + } + + // 2. Tagging Policies (if provided) + if let (Some(alt_tags), Some(policies)) = (opts.alt_tags, opts.tagging) { + return apply_tagging_policies(alt_tags, policies, &metadata); + } + trace!("LocalDriver::generate_tags({opts:?})"); - let os_version = Driver::get_os_version().oci_ref(opts.oci_ref).call()?; - let timestamp = blue_build_utils::get_tag_timestamp(); - let short_sha = commit_sha(); + let os_version = opts.os_version; + let timestamp = opts.timestamp; opts.alt_tags .as_ref() @@ -35,7 +57,7 @@ impl CiDriver for LocalDriver { || { let mut tags = string_vec![ "latest", - ×tamp, + timestamp, format!("{os_version}"), format!("{timestamp}-{os_version}"), ]; diff --git a/process/drivers/opts/ci.rs b/process/drivers/opts/ci.rs index 6897e282..ababca0d 100644 --- a/process/drivers/opts/ci.rs +++ b/process/drivers/opts/ci.rs @@ -1,4 +1,4 @@ -use blue_build_utils::{container::Tag, platform::Platform}; +use blue_build_utils::{container::Tag, platform::Platform, tagging::TaggingPolicy}; use bon::Builder; use oci_client::Reference; @@ -10,6 +10,16 @@ pub struct GenerateTagsOpts<'scope> { #[builder(into)] pub alt_tags: Option<&'scope [Tag]>, + #[builder(into)] + pub tags: Option<&'scope [String]>, + + #[builder(into)] + pub tagging: Option<&'scope [TaggingPolicy]>, + + pub os_version: &'scope str, + pub timestamp: &'scope str, + pub short_sha: Option<&'scope str>, + pub platform: Option, } diff --git a/recipe/src/recipe.rs b/recipe/src/recipe.rs index f32a01f7..fedb2b99 100644 --- a/recipe/src/recipe.rs +++ b/recipe/src/recipe.rs @@ -7,6 +7,7 @@ use std::{ use blue_build_utils::{ constants::COSIGN_IMAGE_VERSION, container::Tag, platform::Platform, secret::Secret, + tagging::TaggingPolicy, }; use bon::Builder; use cached::proc_macro::cached; @@ -59,6 +60,17 @@ pub struct Recipe { #[builder(into)] pub alt_tags: Option>, + /// Exact tags to add to the image. + /// + /// This will override any automatic tagging logic in the drivers. + #[serde(alias = "tags", skip_serializing_if = "Option::is_none")] + #[builder(into)] + pub tags: Option>, + + /// Custom tagging policies for expanding alt-tags. + #[serde(skip_serializing_if = "Option::is_none")] + pub tagging: Option>, + /// The version of nushell to use for modules. #[serde(skip_serializing_if = "Option::is_none", rename = "nushell-version")] pub nushell_version: Option, diff --git a/src/commands/build.rs b/src/commands/build.rs index d0021b89..ea6574bf 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -301,10 +301,32 @@ impl BuildCommand { ); let recipe = &Recipe::parse(recipe_path)?; + let timestamp = &blue_build_utils::get_tag_timestamp(); + let os_version = &Driver::get_os_version() + .oci_ref(&recipe.base_image_ref()?) + .call() + .inspect(|v| trace!("os_version={v}"))?; + let short_sha = { + let mut short_sha = blue_build_utils::get_env_var("GITHUB_SHA") + .or_else(|_| blue_build_utils::get_env_var("CI_COMMIT_SHA")) + .unwrap_or_default(); + short_sha.truncate(7); + if short_sha.is_empty() { + None + } else { + Some(short_sha) + } + }; + let tags = &Driver::generate_tags( GenerateTagsOpts::builder() .oci_ref(&recipe.base_image_ref()?) .maybe_alt_tags(recipe.alt_tags.as_deref()) + .maybe_tags(recipe.tags.as_deref()) + .maybe_tagging(recipe.tagging.as_deref()) + .os_version(os_version) + .timestamp(timestamp) + .maybe_short_sha(short_sha.as_deref()) .maybe_platform(self.platform.first().copied()) .build(), )?; diff --git a/utils/src/lib.rs b/utils/src/lib.rs index ae7afc37..61186b31 100644 --- a/utils/src/lib.rs +++ b/utils/src/lib.rs @@ -8,6 +8,7 @@ pub mod platform; pub mod secret; pub mod semver; pub mod syntax_highlighting; +pub mod tagging; #[cfg(feature = "test")] pub mod test_utils; pub mod traits; diff --git a/utils/src/tagging.rs b/utils/src/tagging.rs new file mode 100644 index 00000000..a26064fa --- /dev/null +++ b/utils/src/tagging.rs @@ -0,0 +1,156 @@ +use crate::container::Tag; +use miette::Result; +use lazy_regex::Regex; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TaggingPolicy { + /// Regex to match the alt-tag name (e.g., 'stable' or '.*') + #[serde(alias = "match")] + pub match_tag: String, + + /// List of tag templates using placeholders (e.g., '{tag}-{os_version}') + pub tags: Vec, +} + +#[derive(Clone)] +pub struct TagMetadata<'a> { + pub tag: Option<&'a str>, + pub os_version: &'a str, + pub timestamp: &'a str, + pub short_sha: Option<&'a str>, +} + +pub fn resolve_tag_template(template: &str, metadata: &TagMetadata) -> String { + let mut resolved = template.to_string(); + + if let Some(tag) = metadata.tag { + resolved = resolved.replace("{tag}", tag); + } + resolved = resolved.replace("{os_version}", metadata.os_version); + resolved = resolved.replace("{timestamp}", metadata.timestamp); + if let Some(short_sha) = metadata.short_sha { + resolved = resolved.replace("{short_sha}", short_sha); + } + + resolved +} + +pub fn apply_tagging_policies( + alt_tags: &[Tag], + policies: &[TaggingPolicy], + metadata: &TagMetadata, +) -> Result> { + let mut expanded_tags = Vec::new(); + + // Pre-compile regexes for each policy + let compiled_policies: Vec<(Regex, &TaggingPolicy)> = policies + .iter() + .map(|p| { + Regex::new(&p.match_tag) + .map_err(|e| miette::miette!("Invalid regex in tagging policy '{}': {}", p.match_tag, e)) + .map(|re| (re, p)) + }) + .collect::>>()?; + + for alt in alt_tags { + let alt_str = alt.as_str(); + + // Find the first policy where the regex matches the alt-tag + let policy = compiled_policies.iter().find(|(re, _): &&(Regex, &TaggingPolicy)| re.is_match(alt_str)); + + if let Some((_, policy)) = policy { + for template in &policy.tags { + let mut meta = metadata.clone(); + meta.tag = Some(alt_str); + expanded_tags.push(resolve_tag_template(template, &meta).parse()?); + } + } + } + + Ok(expanded_tags) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_template() { + let metadata = TagMetadata { + tag: Some("stable"), + os_version: "41", + timestamp: "20241021", + short_sha: Some("abc1234"), + }; + + assert_eq!( + resolve_tag_template("{tag}-{os_version}", &metadata), + "stable-41" + ); + assert_eq!( + resolve_tag_template("{os_version}.{timestamp}", &metadata), + "41.20241021" + ); + assert_eq!( + resolve_tag_template("{tag}-{short_sha}", &metadata), + "stable-abc1234" + ); + } + + #[test] + fn test_apply_policies() { + let policies = vec![ + TaggingPolicy { + match_tag: "stable".to_string(), + tags: vec![ + "{tag}".to_string(), + "{tag}-{os_version}".to_string(), + "{os_version}".to_string(), + ], + }, + TaggingPolicy { + match_tag: "unstable".to_string(), + tags: vec!["{tag}-{os_version}.{timestamp}".to_string()], + }, + ]; + + let metadata = TagMetadata { + tag: None, + os_version: "41", + timestamp: "20241021", + short_sha: None, + }; + + let alt_tags = vec!["stable".parse().unwrap(), "unstable".parse().unwrap()]; + + let result = apply_tagging_policies(&alt_tags, &policies, &metadata).unwrap(); + + assert_eq!(result.len(), 4); + assert_eq!(result[0].as_str(), "stable"); + assert_eq!(result[1].as_str(), "stable-41"); + assert_eq!(result[2].as_str(), "41"); + assert_eq!(result[3].as_str(), "unstable-41.20241021"); + } + + #[test] + fn test_regex_matching() { + let policies = vec![TaggingPolicy { + match_tag: "^v.*$".to_string(), + tags: vec!["release-{tag}".to_string()], + }]; + + let metadata = TagMetadata { + tag: None, + os_version: "41", + timestamp: "20241021", + short_sha: None, + }; + + let alt_tags = vec!["v1.0".parse().unwrap(), "latest".parse().unwrap()]; + let result = apply_tagging_policies(&alt_tags, &policies, &metadata).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].as_str(), "release-v1.0"); + } +}