From 1468984cb3a0151a570137a20693f162e1dabf01 Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 18 Aug 2026 13:58:05 +0200 Subject: [PATCH 1/5] feat(boil): Support for per-image build arguments --- rust/boil/src/core/bakefile.rs | 3 ++- rust/boil/src/core/image.rs | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/boil/src/core/bakefile.rs b/rust/boil/src/core/bakefile.rs index c6e7234f7..77bb9379d 100644 --- a/rust/boil/src/core/bakefile.rs +++ b/rust/boil/src/core/bakefile.rs @@ -385,7 +385,8 @@ impl Bakefile { // TODO (@Techassi): Clean this up // TODO (@Techassi): Move the arg formatting into functions - let mut build_arguments = docker::BuildArguments::new(); + // Start of with the shared (across all versions of the same image) build arguments. + let mut build_arguments = image_config.build_arguments.clone(); let local_version_docker_args: Vec<_> = image_options .local_images diff --git a/rust/boil/src/core/image.rs b/rust/boil/src/core/image.rs index f763823b0..f6d4f81db 100644 --- a/rust/boil/src/core/image.rs +++ b/rust/boil/src/core/image.rs @@ -9,7 +9,7 @@ use std::{ use serde::Deserialize; use snafu::{ResultExt as _, Snafu, ensure}; -use crate::core::docker; +use crate::core::docker::{self, BuildArguments}; #[derive(Debug, PartialEq, Snafu)] pub enum ParseImageSelectorError { @@ -111,10 +111,15 @@ pub enum ImageConfigError { } #[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] pub struct ImageConfig { #[serde(default)] pub metadata: ImageMetadata, + /// Shared build arguments across all versions, but can be overwritten on a image version level. + #[serde(default)] + pub build_arguments: BuildArguments, + pub versions: ImageVersions, } From 1b0ce2d8888d94acab5728c01fa62d19cb7a0c79 Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 18 Aug 2026 16:32:05 +0200 Subject: [PATCH 2/5] refactor(boil): Rework build argument internals This improves and simplifies the build argument internals by using a BTreeMap instead of a BTreeSet. Keys and values are now properly separated and thus a map can be used with all the obvious benefits. These benefits include automatic de-duplication of keys allowing overrides at different levels of hierarchy (eg. per-image shared build arguments vs per-version ones). It additionally removes wrapper code which was only needed to forward implementations of the inner type through the newtype. --- rust/boil/src/core/bakefile.rs | 47 +++----- rust/boil/src/core/docker.rs | 198 +++++++++++---------------------- rust/boil/src/core/image.rs | 6 +- 3 files changed, 81 insertions(+), 170 deletions(-) diff --git a/rust/boil/src/core/bakefile.rs b/rust/boil/src/core/bakefile.rs index 77bb9379d..a9c11d087 100644 --- a/rust/boil/src/core/bakefile.rs +++ b/rust/boil/src/core/bakefile.rs @@ -311,10 +311,12 @@ impl Bakefile { let date_time = Self::now()?; // Load build arguments from a file if the user requested it - let mut user_container_build_args = cli_args.build_arguments.clone(); + let mut user_container_build_args: docker::BuildArguments = + docker::build_args_vec_to_btree_map(cli_args.build_arguments.clone()); + if let Some(path) = &cli_args.build_arguments_file { let build_arguments_from_file = - docker::BuildArguments::from_file(path).context(ParseBuildArgumentsSnafu)?; + docker::build_args_from_file(path).context(ParseBuildArgumentsSnafu)?; user_container_build_args.extend(build_arguments_from_file); } @@ -388,40 +390,24 @@ impl Bakefile { // Start of with the shared (across all versions of the same image) build arguments. let mut build_arguments = image_config.build_arguments.clone(); - let local_version_docker_args: Vec<_> = image_options + let local_version_docker_args: docker::BuildArguments = image_options .local_images .iter() .map(|(image_name, image_version)| { - docker::BuildArgument::local_image_version( - image_name.to_string(), - image_version.to_string(), - ) + let key = docker::BuildArgumentKey::local_image_key(image_name); + (key, image_version.to_owned()) }) .collect(); build_arguments.extend(image_options.build_arguments); build_arguments.extend(local_version_docker_args); + // TODO (@Techassi): Rename this to IMAGE_VERSION - build_arguments.insert(docker::BuildArgument::new( - "PRODUCT_VERSION".to_owned(), - image_version.to_string(), - )); - build_arguments.insert(docker::BuildArgument::new( - "IMAGE_REPOSITORY_URI".to_owned(), - image_repository_uri.clone(), - )); - build_arguments.insert(docker::BuildArgument::new( - "IMAGE_INDEX_MANIFEST_TAG".to_owned(), - image_index_manifest_tag, - )); - build_arguments.insert(docker::BuildArgument::new( - "IMAGE_MANIFEST_TAG".to_owned(), - image_manifest_tag, - )); - build_arguments.insert(docker::BuildArgument::new( - "IMAGE_MANIFEST_URI".to_owned(), - image_manifest_uri.clone(), - )); + build_arguments.insert("PRODUCT_VERSION".into(), image_version.to_string()); + build_arguments.insert("IMAGE_REPOSITORY_URI".into(), image_repository_uri.clone()); + build_arguments.insert("IMAGE_INDEX_MANIFEST_TAG".into(), image_index_manifest_tag); + build_arguments.insert("IMAGE_MANIFEST_TAG".into(), image_manifest_tag); + build_arguments.insert("IMAGE_MANIFEST_URI".into(), image_manifest_uri.clone()); let tags = if let Some(floating_vendor_version) = floating_vendor_version.as_deref() { @@ -642,7 +628,7 @@ impl BakefileTarget { revision: String, release_version: String, container_build_args: docker::BuildArguments, - user_container_build_args: Vec, + user_container_build_args: docker::BuildArguments, metadata: &MetadataOptions, ) -> Self { let config::MetadataOptions { @@ -684,10 +670,7 @@ impl BakefileTarget { let mut arguments = container_build_args; arguments.extend(user_container_build_args); - arguments.insert(docker::BuildArgument::new( - "RELEASE_VERSION".to_owned(), - release_version, - )); + arguments.insert("RELEASE_VERSION".into(), release_version); // Labels describe Docker resources, and con be considered legacy. We // should use annotations instead. These labels are only added to be diff --git a/rust/boil/src/core/docker.rs b/rust/boil/src/core/docker.rs index f084a293a..7f91e734d 100644 --- a/rust/boil/src/core/docker.rs +++ b/rust/boil/src/core/docker.rs @@ -1,12 +1,10 @@ use std::{ - collections::BTreeSet, - fmt::Display, - ops::{Deref, DerefMut}, + collections::BTreeMap, path::{Path, PathBuf}, str::FromStr, }; -use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeMap}; +use serde::{Deserialize, Serialize, de::Visitor}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; #[derive(Debug, Snafu)] @@ -18,22 +16,22 @@ pub enum ParseBuildArgumentError { NonAscii, } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct BuildArgument((String, String)); +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +pub struct BuildArgumentKey(String); -impl BuildArgument { - pub fn new(key: String, value: String) -> Self { - let key = Self::format_key(key); - Self((key, value)) - } - - pub fn local_image_version(image_name: String, image_version: String) -> Self { - Self::new(format!("{image_name}_VERSION"), image_version) +impl From for BuildArgumentKey +where + T: Into, +{ + fn from(value: T) -> Self { + Self(value.into()) } +} - fn format_key(key: impl AsRef) -> String { - key.as_ref().replace(['-', '/'], "_").to_uppercase() - } +#[derive(Clone, Debug)] +pub struct BuildArgument { + pub key: BuildArgumentKey, + pub value: String, } impl FromStr for BuildArgument { @@ -43,42 +41,22 @@ impl FromStr for BuildArgument { ensure!(s.is_ascii(), NonAsciiSnafu); let (key, value) = s.split_once('=').context(InvalidFormatSnafu)?; - let key = Self::format_key(key); + let key = BuildArgumentKey::new(key); - Ok(Self((key, value.to_owned()))) + Ok(Self { + key, + value: value.to_owned(), + }) } } -impl<'de> Deserialize<'de> for BuildArgument { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct BuildArgumentVisitor; - - impl Visitor<'_> for BuildArgumentVisitor { - type Value = BuildArgument; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, "a valid build argument") - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - BuildArgument::from_str(v).map_err(serde::de::Error::custom) - } - } - - deserializer.deserialize_str(BuildArgumentVisitor) +impl BuildArgumentKey { + pub fn new(name: impl AsRef) -> Self { + Self(name.as_ref().replace(['-', '/'], "_").to_uppercase()) } -} -impl Display for BuildArgument { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let (key, value) = &self.0; - write!(f, "{key}={value}") + pub fn local_image_key(image_name: &str) -> Self { + Self::new(format!("{image_name}_VERSION")) } } @@ -94,107 +72,57 @@ pub enum ParseBuildArgumentsError { ParseBuildArgument { source: ParseBuildArgumentError }, } -#[derive(Clone, Debug, Default)] -pub struct BuildArguments(BTreeSet); - -impl Deref for BuildArguments { - type Target = BTreeSet; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} +pub type BuildArguments = BTreeMap; -impl DerefMut for BuildArguments { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} +/// Custom [`serde::Deserialize`] implementation to ensure we properly format the keys. +pub fn deserialize_args<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + struct BuildArgumentsVisitor; -impl Extend for BuildArguments { - fn extend>(&mut self, iter: T) { - self.0.extend(iter); - } -} + impl<'de> Visitor<'de> for BuildArgumentsVisitor { + type Value = BuildArguments; -impl IntoIterator for BuildArguments { - type IntoIter = std::collections::btree_set::IntoIter; - type Item = BuildArgument; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } -} - -impl<'de> Deserialize<'de> for BuildArguments { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct BuildArgumentsVisitor; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a map of valid build arguments") + } - impl<'de> Visitor<'de> for BuildArgumentsVisitor { - type Value = BuildArguments; + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut args = BTreeMap::new(); - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, "a map of valid build arguments") + while let Some((key, value)) = map.next_entry::<&str, _>()? { + args.insert(BuildArgumentKey::new(key), value); } - fn visit_map(self, mut map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - let mut args = BTreeSet::new(); - - while let Some((key, value)) = map.next_entry()? { - args.insert(BuildArgument::new(key, value)); - } - - Ok(BuildArguments(args)) - } + Ok(args) } - - deserializer.deserialize_map(BuildArgumentsVisitor) } -} -impl Serialize for BuildArguments { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut map = serializer.serialize_map(Some(self.len()))?; - - for BuildArgument((key, value)) in &self.0 { - map.serialize_entry(&key, &value)?; - } - - map.end() - } + deserializer.deserialize_map(BuildArgumentsVisitor) } -impl BuildArguments { - pub fn new() -> Self { - Self(BTreeSet::new()) - } +// We sadly cannot use a From> for BuildArguments impl here because of the +// orphan rule. +pub fn build_args_vec_to_btree_map(vec: Vec) -> BuildArguments { + vec.into_iter().map(|arg| (arg.key, arg.value)).collect() +} - pub fn is_empty(&self) -> bool { - self.0.is_empty() +pub fn build_args_from_file

(path: P) -> Result +where + P: AsRef, +{ + let path = path.as_ref(); + let content = std::fs::read_to_string(path).context(ReadFileSnafu { path })?; + let mut args = BTreeMap::new(); + + for line in content.lines() { + let arg = BuildArgument::from_str(line).context(ParseBuildArgumentSnafu)?; + args.insert(arg.key, arg.value); } - pub fn from_file

(path: P) -> Result - where - P: AsRef, - { - let path = path.as_ref(); - let content = std::fs::read_to_string(path).context(ReadFileSnafu { path })?; - let mut args = Self::new(); - - for line in content.lines() { - let arg = BuildArgument::from_str(line).context(ParseBuildArgumentSnafu)?; - args.insert(arg); - } - - Ok(args) - } + Ok(args) } diff --git a/rust/boil/src/core/image.rs b/rust/boil/src/core/image.rs index f6d4f81db..37ec2bcbe 100644 --- a/rust/boil/src/core/image.rs +++ b/rust/boil/src/core/image.rs @@ -117,7 +117,7 @@ pub struct ImageConfig { pub metadata: ImageMetadata, /// Shared build arguments across all versions, but can be overwritten on a image version level. - #[serde(default)] + #[serde(default, deserialize_with = "docker::deserialize_args")] pub build_arguments: BuildArguments, pub versions: ImageVersions, @@ -184,12 +184,12 @@ pub struct ImageVersionOptions { // NOTE (@Techassi): Potentially add a dependencies field here which will be automatically be // suffixed with _VERSION. - #[serde(default)] + #[serde(default, deserialize_with = "docker::deserialize_args")] pub build_arguments: docker::BuildArguments, /// A custom path to a Dockerfile/Containerfile for a particular version of an image. /// - /// This is usefull for cases where the same image is being built differently depending on it's + /// This is useful for cases where the same image is being built differently depending on it's /// version and it is too difficult/messy to do it the same Dockerfile/Containerfile. #[serde(alias = "containerfile")] pub dockerfile: Option, From 750670e3bdbcce85100f8f58aa05f75d50c72f0a Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 18 Aug 2026 16:43:18 +0200 Subject: [PATCH 3/5] chore: Apply suggestion Co-authored-by: Lukas Krug --- rust/boil/src/core/bakefile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/boil/src/core/bakefile.rs b/rust/boil/src/core/bakefile.rs index a9c11d087..da33286b9 100644 --- a/rust/boil/src/core/bakefile.rs +++ b/rust/boil/src/core/bakefile.rs @@ -387,7 +387,7 @@ impl Bakefile { // TODO (@Techassi): Clean this up // TODO (@Techassi): Move the arg formatting into functions - // Start of with the shared (across all versions of the same image) build arguments. + // Start off with the shared (across all versions of the same image) build arguments. let mut build_arguments = image_config.build_arguments.clone(); let local_version_docker_args: docker::BuildArguments = image_options From a5ccbd23bfa955197867b8dd93ed49519e03d14a Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 18 Aug 2026 17:17:23 +0200 Subject: [PATCH 4/5] fix(boil): Use custom build arg deserialization in global config --- rust/boil/src/config.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/boil/src/config.rs b/rust/boil/src/config.rs index b84f8c58b..afffd5988 100644 --- a/rust/boil/src/config.rs +++ b/rust/boil/src/config.rs @@ -16,6 +16,8 @@ pub enum ConfigError { #[derive(Debug, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct Config { + /// Global build arguments which apply to all images. + #[serde(default, deserialize_with = "docker::deserialize_args")] pub build_arguments: docker::BuildArguments, pub metadata: MetadataOptions, } From 6e856bc596e708e2bd60154309a133b4dc084d24 Mon Sep 17 00:00:00 2001 From: Techassi Date: Tue, 18 Aug 2026 17:18:12 +0200 Subject: [PATCH 5/5] docs(boil): Add per-image build-arguments to README --- rust/boil/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rust/boil/README.md b/rust/boil/README.md index b8f8f7e56..614d422dd 100644 --- a/rust/boil/README.md +++ b/rust/boil/README.md @@ -63,6 +63,10 @@ images, and registry metadata. [metadata.registries] "oci.example.org" = { namespace = "my/namespace" } # Used for image checks +[build-arguments] +BAR = "baz" # Specify build arguments which apply to all versions. + # Can be overwritten on a per-version basis + [versions."1.2.3".local-images] # Specify references to local images per version foo = "1.2.3" bar = "4.5.6"