From 691448766245e8676e6cba546e78464b865cf7e2 Mon Sep 17 00:00:00 2001 From: Gerald Pinder Date: Fri, 10 Apr 2026 17:21:55 -0400 Subject: [PATCH] feat: Add environment variable expansion in top-level recipe fields This adds the ability to set environment variables to control the top-level fields of the recipe. This is useful for easily changing where you pull your images from or bumping the base image version without updating the file directly. It allows matrixing in Gitlab CI or Github Actions to be more customizable. This follows similar POSIX shell expansion logic. Information about the implementation can be found [here](https://docs.rs/shellexpand/latest/shellexpand/) --- .github/workflows/test.yml | 39 ++++++++ Cargo.lock | 19 ++++ .../recipes/recipe-env-expansion.yml | 12 +++ justfile | 12 +++ recipe/src/recipe.rs | 12 ++- src/commands/generate.rs | 4 +- utils/Cargo.toml | 1 + utils/src/container.rs | 28 +++--- utils/src/env_str.rs | 98 +++++++++++++++++++ utils/src/lib.rs | 1 + 10 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 integration-tests/test-repo/recipes/recipe-env-expansion.yml create mode 100644 utils/src/env_str.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b0282bb2..99fa96a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -169,6 +169,45 @@ jobs: COSIGN_PRIVATE_KEY: ${{ secrets.TEST_SIGNING_SECRET }} run: just test-empty-files-build + env-expand-build: + timeout-minutes: 60 + runs-on: ubuntu-latest + permissions: + contents: read # read repo contents + packages: write # write test package to ghcr + id-token: write # docker auth + + steps: + - name: Maximize build space + uses: ublue-os/remove-unwanted-software@cc0becac701cf642c8f0a6613bbdaf5dc36b259e # v9 + + - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + install: true + + - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 + + # Setup repo and add caching + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ inputs.ref }} + repository: ${{ inputs.repo }} + + + - uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 + + - name: Run Build + env: + GH_TOKEN: ${{ github.token }} + GH_PR_EVENT_NUMBER: ${{ inputs.pr_event_number }} + COSIGN_PRIVATE_KEY: ${{ secrets.TEST_SIGNING_SECRET }} + run: just test-env-expansion-build + bluefin-build: timeout-minutes: 60 runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 4b6ed223..ffade7d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -585,6 +585,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "shellexpand", "syntect", "tempfile", "uuid", @@ -1321,6 +1322,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -4637,6 +4647,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/integration-tests/test-repo/recipes/recipe-env-expansion.yml b/integration-tests/test-repo/recipes/recipe-env-expansion.yml new file mode 100644 index 00000000..317957bb --- /dev/null +++ b/integration-tests/test-repo/recipes/recipe-env-expansion.yml @@ -0,0 +1,12 @@ +--- +# yaml-language-server: $schema=https://schema.blue-build.org/recipe-v1.json +name: cli/test-${NAME_EXT:-env-expansion} +description: This is my personal OS image. ${DESC_EXT} +base-image: ${BASE_REGISTRY:-quay.io}/fedora/fedora-bootc +blue-build-tag: none +cosign-version: none +image-version: ${VERSION} +stages: + - from-file: stages.yml +modules: + - from-file: common.yml diff --git a/justfile b/justfile index f99f0288..b5bcc961 100644 --- a/justfile +++ b/justfile @@ -166,6 +166,18 @@ test-empty-files-build: generate-test-secret install-debug-all-features {{ should_push }} \ -vv +test-env-expansion-build: generate-test-secret install-debug-all-features + cd integration-tests/test-repo \ + && DESC_EXT="Test description" \ + VERSION="43" \ + bluebuild build \ + --retry-push \ + -B docker \ + -S sigstore \ + {{ should_push }} \ + -vv \ + recipes/recipe-env-expansion.yml + test-bluefin-build: generate-test-secret install-debug-all-features cd integration-tests/test-repo \ && bluebuild build \ diff --git a/recipe/src/recipe.rs b/recipe/src/recipe.rs index f32a01f7..490a245a 100644 --- a/recipe/src/recipe.rs +++ b/recipe/src/recipe.rs @@ -6,7 +6,8 @@ use std::{ }; use blue_build_utils::{ - constants::COSIGN_IMAGE_VERSION, container::Tag, platform::Platform, secret::Secret, + constants::COSIGN_IMAGE_VERSION, container::Tag, env_str::EnvString, platform::Platform, + secret::Secret, }; use bon::Builder; use cached::proc_macro::cached; @@ -24,21 +25,22 @@ use crate::{Module, ModuleExt, StagesExt, maybe_version::MaybeVersion}; /// base image to assist with building the Containerfile /// and tagging the image appropriately. #[derive(Default, Serialize, Clone, Deserialize, Debug, Builder)] -#[builder(on(String, into))] +#[allow(clippy::duplicated_attributes)] +#[builder(on(EnvString, into), on(String, into))] pub struct Recipe { /// The name of the user's image. /// /// This will be set on the `org.opencontainers.image.title` label. - pub name: String, + pub name: EnvString, /// The description of the user's image. /// /// This will be set on the `org.opencontainers.image.description` label. - pub description: String, + pub description: EnvString, /// The base image from which to build the user's image. #[serde(alias = "base-image")] - pub base_image: String, + pub base_image: EnvString, /// The version/tag of the base image. #[serde(alias = "image-version")] diff --git a/src/commands/generate.rs b/src/commands/generate.rs index 518ff4ff..6d80b097 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -249,11 +249,11 @@ pub fn generate_default_labels(recipe: &Recipe) -> Result for ImageRef<'_> { } #[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Tag(String); +pub struct Tag(EnvString); impl Tag { #[must_use] @@ -283,15 +283,18 @@ impl Tag { } } +fn eval_tag(haystack: &EnvString) -> bool { + regex!(r"[\w][\w.-]{0,127}").is_match(haystack) +} + impl FromStr for Tag { type Err = miette::Error; fn from_str(s: &str) -> Result { - let regex = regex!(r"[\w][\w.-]{0,127}"); - regex - .is_match(s) - .then(|| Self(s.into())) - .ok_or_else(|| miette!("Invalid tag: {s}")) + let expanded = EnvString::from(s); + eval_tag(&expanded) + .then(|| Self(expanded.clone())) + .ok_or_else(|| miette!("Invalid tag: {expanded}")) } } @@ -317,24 +320,27 @@ impl<'de> Deserialize<'de> for Tag { where D: serde::Deserializer<'de>, { - Self::from_str(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + let expanded = EnvString::deserialize(deserializer)?; + eval_tag(&expanded) + .then(|| Self(expanded.clone())) + .ok_or_else(|| serde::de::Error::custom(format!("Invalid tag: {expanded}"))) } } impl Default for Tag { fn default() -> Self { - Self(String::from("latest")) + Self(String::from("latest").into()) } } impl From for String { fn from(value: Tag) -> Self { - value.0 + value.0.to_string() } } impl From<&Tag> for String { fn from(value: &Tag) -> Self { - value.0.clone() + value.0.to_string() } } diff --git a/utils/src/env_str.rs b/utils/src/env_str.rs new file mode 100644 index 00000000..ed40ffe9 --- /dev/null +++ b/utils/src/env_str.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Eq)] +pub struct EnvString { + unexpanded: String, + expanded: String, +} + +impl<'de> Deserialize<'de> for EnvString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let unexpanded = String::deserialize(deserializer)?; + let expanded = shellexpand::env(&unexpanded) + .map_err(serde::de::Error::custom)? + .into(); + + Ok(Self { + unexpanded, + expanded, + }) + } +} + +impl Serialize for EnvString { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.unexpanded) + } +} + +impl std::fmt::Display for EnvString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.expanded) + } +} + +impl std::ops::Deref for EnvString { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.expanded.as_str() + } +} + +impl From for EnvString { + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + +impl From<&String> for EnvString { + fn from(value: &String) -> Self { + Self::from(value.as_str()) + } +} + +/// Context function for env var expansion. +/// If the variable doesn't exist, return None to prevent expansion. +fn context(var: &str) -> Option { + crate::get_env_var(var).ok() +} + +impl From<&str> for EnvString { + fn from(value: &str) -> Self { + Self { + unexpanded: value.to_string(), + expanded: shellexpand::env_with_context_no_errors(&value, context).to_string(), + } + } +} + +impl From for String { + fn from(value: EnvString) -> Self { + value.expanded + } +} + +impl PartialEq for EnvString { + fn eq(&self, other: &Self) -> bool { + self.expanded.eq(&other.expanded) + } +} + +impl Ord for EnvString { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.expanded.cmp(&other.expanded) + } +} + +impl PartialOrd for EnvString { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} diff --git a/utils/src/lib.rs b/utils/src/lib.rs index ae7afc37..98c0934a 100644 --- a/utils/src/lib.rs +++ b/utils/src/lib.rs @@ -3,6 +3,7 @@ pub mod command_output; pub mod constants; pub mod container; pub mod credentials; +pub mod env_str; mod macros; pub mod platform; pub mod secret;