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;