diff --git a/CHANGELOG.md b/CHANGELOG.md index 761c2d6e8..32aa3f315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ All notable changes to this project will be documented in this file. ### Added +- Typed `Merge` trait ([#368]). - New commons::s3 module with common S3 connection structs ([#377]). - New `TlsAuthenticationProvider` for `AuthenticationClass` ([#387]). +[#368]: https://github.com/stackabletech/operator-rs/pull/368 [#377]: https://github.com/stackabletech/operator-rs/issues/377 [#387]: https://github.com/stackabletech/operator-rs/pull/387 diff --git a/Cargo.toml b/Cargo.toml index 5a8b459b6..cb38e8fe6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ derivative = "2.2.0" tracing-opentelemetry = "0.17.2" opentelemetry = { version = "0.17.0", features = ["rt-tokio"] } opentelemetry-jaeger = { version = "0.16.0", features = ["rt-tokio"] } +stackable-operator-derive = { path = "stackable-operator-derive" } [dev-dependencies] rstest = "0.12.0" @@ -44,3 +45,6 @@ serde_yaml = "0.8" default = ["native-tls"] native-tls = ["kube/native-tls"] rustls-tls = ["kube/rustls-tls"] + +[workspace] +members = ["stackable-operator-derive"] diff --git a/src/config/merge.rs b/src/config/merge.rs new file mode 100644 index 000000000..e724dc991 --- /dev/null +++ b/src/config/merge.rs @@ -0,0 +1,392 @@ +use std::{ + collections::{btree_map, hash_map, BTreeMap, HashMap}, + hash::Hash, +}; + +pub use stackable_operator_derive::Merge; + +/// A type that can be merged with itself +/// +/// This is primarily intended to be implemented for configuration values that can come from several sources, for example +/// configuration files with different scopes (role group, role, cluster) where a tighter scope should take precedence. +/// +/// Most users will want to implement this for custom types using [the associated derive macro](`derive@Merge`). +/// +/// # Example +/// +/// ``` +/// # use stackable_operator::config::merge::Merge; +/// +/// #[derive(Merge, Debug, PartialEq, Eq)] +/// struct Foo { +/// bar: Option, +/// baz: Option, +/// } +/// +/// let mut config = Foo { +/// bar: Some(0), +/// baz: None, +/// }; +/// config.merge(&Foo { +/// bar: Some(1), +/// baz: Some(2), +/// }); +/// assert_eq!(config, Foo { +/// bar: Some(0), // Overridden by `bar: Some(0)` above +/// baz: Some(2), // Fallback is used +/// }); +/// ``` +/// +/// # Options +/// +/// A field should be [`Option`]al if it is [`Atomic`] (for example: [`u8`]) or an enum (since the discriminant matters in this case). +/// Composite objects (such as regular structs) should generally *not* be optional. +pub trait Merge { + /// Merge with `defaults`, preferring values from `self` if they are set there + fn merge(&mut self, defaults: &Self); +} + +impl Merge for Box { + fn merge(&mut self, defaults: &Self) { + T::merge(self, defaults) + } +} +impl Merge for BTreeMap { + fn merge(&mut self, defaults: &Self) { + for (k, default_v) in defaults { + match self.entry(k.clone()) { + btree_map::Entry::Occupied(mut entry) => { + entry.get_mut().merge(default_v); + } + btree_map::Entry::Vacant(entry) => { + entry.insert(default_v.clone()); + } + } + } + } +} +impl Merge for HashMap { + fn merge(&mut self, defaults: &Self) { + for (k, default_v) in defaults { + match self.entry(k.clone()) { + hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().merge(default_v); + } + hash_map::Entry::Vacant(entry) => { + entry.insert(default_v.clone()); + } + } + } + } +} + +/// A marker trait for types that are merged atomically (as one single value) rather than +/// trying to merge each field individually +pub trait Atomic: Clone {} +impl Atomic for u8 {} +impl Atomic for u16 {} +impl Atomic for u32 {} +impl Atomic for u64 {} +impl Atomic for u128 {} +impl Atomic for usize {} +impl Atomic for i8 {} +impl Atomic for i16 {} +impl Atomic for i32 {} +impl Atomic for i64 {} +impl Atomic for i128 {} +impl Atomic for isize {} +impl Atomic for bool {} +impl Atomic for String {} +impl<'a> Atomic for &'a str {} + +impl Merge for Option { + fn merge(&mut self, defaults: &Self) { + if self.is_none() { + *self = defaults.clone(); + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use super::Merge; + + /// Moving version of [`Merge::merge`], to produce slightly nicer test output + fn merge(mut overrides: T, defaults: &T) -> T { + overrides.merge(defaults); + overrides + } + + #[derive(Debug, PartialEq, Eq, Clone)] + struct Accumulator(u8); + impl Merge for Accumulator { + fn merge(&mut self, defaults: &Self) { + self.0 += defaults.0 + } + } + + #[test] + fn merge_derived_struct() { + #[derive(Merge, PartialEq, Eq, Debug)] + #[merge(path_overrides(merge = "super"))] + struct Mergeable { + one: Option, + two: Option, + } + + assert_eq!( + merge( + Mergeable { + one: None, + two: None, + }, + &Mergeable { + one: Some(1), + two: None, + } + ), + Mergeable { + one: Some(1), + two: None, + } + ); + assert_eq!( + merge( + Mergeable { + one: Some(0), + two: None, + }, + &Mergeable { + one: Some(1), + two: None, + } + ), + Mergeable { + one: Some(0), + two: None, + } + ); + assert_eq!( + merge( + Mergeable { + one: Some(0), + two: None, + }, + &Mergeable { + one: Some(1), + two: Some(false), + } + ), + Mergeable { + one: Some(0), + two: Some(false), + } + ); + } + + #[test] + fn merge_nested_derived_struct() { + #[derive(Merge, PartialEq, Eq, Debug)] + #[merge(path_overrides(merge = "super"))] + struct Parent { + one: Option, + child: Child, + } + #[derive(Merge, PartialEq, Eq, Debug)] + #[merge(path_overrides(merge = "super"))] + struct Child { + two: Option, + three: Option, + } + + assert_eq!( + merge( + Parent { + one: Some(0), + child: Child { + two: None, + three: Some(true), + } + }, + &Parent { + one: None, + child: Child { + two: Some(1), + three: Some(false), + } + }, + ), + Parent { + one: Some(0), + child: Child { + two: Some(1), + three: Some(true) + }, + } + ); + } + + #[test] + fn merge_derived_struct_with_generics() { + #[derive(Merge, PartialEq, Eq, Debug)] + #[merge(bounds = "B: Merge", path_overrides(merge = "super"))] + struct Mergeable<'a, B, const C: u8> { + one: Option<&'a str>, + two: B, + three: ParametrizedUnit, + } + #[derive(PartialEq, Eq, Debug)] + struct ParametrizedUnit; + impl Merge for ParametrizedUnit { + fn merge(&mut self, _defaults: &Self) {} + } + + assert_eq!( + merge( + Mergeable { + one: None, + two: Some(23), + three: ParametrizedUnit::<23>, + }, + &Mergeable { + one: Some("abc"), + two: None, + three: ParametrizedUnit, + }, + ), + Mergeable { + one: Some("abc"), + two: Some(23), + three: ParametrizedUnit, + } + ); + } + + #[test] + fn merge_derived_tuple_struct() { + #[derive(Merge, PartialEq, Eq, Debug)] + #[merge(path_overrides(merge = "super"))] + struct Mergeable(Option, Option); + + assert_eq!( + merge(Mergeable(Some(1), None), &Mergeable(Some(2), Some(3))), + Mergeable(Some(1), Some(3)) + ); + } + + #[test] + fn merge_derived_enum() { + #[derive(Merge, PartialEq, Eq, Debug, Clone)] + #[merge(path_overrides(merge = "super"))] + enum Mergeable { + Foo { one: Option, two: Option }, + Bar(Option), + } + + assert_eq!( + merge( + Some(Mergeable::Foo { + one: Some(1), + two: None, + }), + &Some(Mergeable::Foo { + one: Some(2), + two: Some(3), + }), + ), + Some(Mergeable::Foo { + one: Some(1), + two: Some(3), + }) + ); + + assert_eq!( + merge( + Some(Mergeable::Foo { + one: Some(1), + two: Some(2), + }), + &None, + ), + Some(Mergeable::Foo { + one: Some(1), + two: Some(2), + }) + ); + assert_eq!( + merge( + None, + &Some(Mergeable::Foo { + one: Some(1), + two: Some(2), + }), + ), + Some(Mergeable::Foo { + one: Some(1), + two: Some(2), + }) + ); + + assert_eq!( + merge( + Some(Mergeable::Foo { + one: None, + two: None, + }), + &Some(Mergeable::Bar(None)) + ), + Some(Mergeable::Foo { + one: None, + two: None, + }) + ); + + // This is more of a consequence of how enums are merged, but it's worth calling out explicitly + // When the enum variant mismatches, *all* default fields are discarded and entirely replaced with the new variant + assert_eq!( + merge( + Some(Mergeable::Foo { + one: Some(1), + two: None, + }), + &merge( + Some(Mergeable::Bar(None)), + &Some(Mergeable::Foo { + one: None, + two: Some(2), + }) + ) + ), + Some(Mergeable::Foo { + one: Some(1), + two: None, + }) + ); + } + + #[test] + fn merge_hash_map() { + use self::Accumulator as Acc; + assert_eq!( + merge( + HashMap::from([("a", Acc(1)), ("b", Acc(2))]), + &[("a", Acc(3)), ("c", Acc(5))].into() + ), + HashMap::from([("a", Acc(4)), ("b", Acc(2)), ("c", Acc(5))]) + ); + } + + #[test] + fn merge_btree_map() { + use self::Accumulator as Acc; + assert_eq!( + merge( + BTreeMap::from([("a", Acc(1)), ("b", Acc(2))]), + &[("a", Acc(3)), ("c", Acc(5))].into() + ), + BTreeMap::from([("a", Acc(4)), ("b", Acc(2)), ("c", Acc(5))]) + ); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 000000000..9e02b93bd --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1 @@ +pub mod merge; diff --git a/src/lib.rs b/src/lib.rs index 34e3ab6c5..85a2c6d3c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod builder; pub mod cli; pub mod client; pub mod commons; +pub mod config; pub mod crd; pub mod error; pub mod label_selector; diff --git a/stackable-operator-derive/Cargo.toml b/stackable-operator-derive/Cargo.toml new file mode 100644 index 000000000..da9b3cd34 --- /dev/null +++ b/stackable-operator-derive/Cargo.toml @@ -0,0 +1,20 @@ +[package] +authors = ["Stackable GmbH "] +description = "Stackable Operator Framework" +edition = "2021" +license = "Apache-2.0" +name = "stackable-operator-derive" +version = "0.17.0" +repository = "https://github.com/stackabletech/operator-rs" + +[lib] +proc-macro = true + +[dependencies] +darling = "0.13.4" +proc-macro2 = "1.0.37" +quote = "1.0.17" +syn = "1.0.91" + +[dev-dependencies] +stackable-operator = { path = ".." } \ No newline at end of file diff --git a/stackable-operator-derive/src/lib.rs b/stackable-operator-derive/src/lib.rs new file mode 100644 index 000000000..db4bae179 --- /dev/null +++ b/stackable-operator-derive/src/lib.rs @@ -0,0 +1,226 @@ +use darling::{ + ast::{Data, Fields}, + FromDeriveInput, FromField, FromMeta, FromVariant, +}; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::{format_ident, quote}; +use syn::{parse_macro_input, parse_quote, Generics, Index, Path, WherePredicate}; + +#[derive(FromMeta)] +struct PathOverrides { + #[darling(default = "PathOverrides::default_merge")] + merge: Path, +} +impl Default for PathOverrides { + fn default() -> Self { + Self { + merge: Self::default_merge(), + } + } +} +impl PathOverrides { + fn default_merge() -> Path { + parse_quote!(::stackable_operator::config::merge) + } +} + +#[derive(FromDeriveInput)] +#[darling(attributes(merge))] +struct MergeInput { + ident: Ident, + generics: Generics, + data: Data, + #[darling(default)] + path_overrides: PathOverrides, + #[darling(default)] + bounds: Option>, +} + +#[derive(FromField)] +struct MergeField { + ident: Option, +} + +#[derive(FromVariant)] +struct MergeVariant { + ident: Ident, + fields: Fields, +} + +#[derive(Debug, PartialEq, Eq)] +enum InputType { + Struct, + Enum, +} + +/// Derives [`Merge`](trait.Merge.html) for a given struct or enum, by merging each field individually. +/// +/// For enums, all values of the previous variant are discarded if the variant is changed, even if the same field exists in both variants. +/// +/// # Supported attributes +/// +/// ## `#[merge(bounds = "...")]` +/// +/// This attribute can be used to specify additional `where` clauses on the derived trait implementation. +/// Bounds specified on the struct itself are automatically inherited for the generated implementation, and +/// do not need to be repeated here. +/// +/// For example, this: +/// +/// ``` +/// # use stackable_operator::config::merge::Merge; +/// #[derive(Merge)] +/// #[merge(bounds = "T: Merge")] +/// struct Wrapper where T: Clone { +/// inner: T, +/// } +/// ``` +/// +/// Expands to (roughly) the following: +/// +/// ``` +/// # use stackable_operator::config::merge::Merge; +/// struct Wrapper where T: Clone { +/// inner: T, +/// } +/// impl Merge for Wrapper +/// where +/// T: Clone, // this clause was inherited from the struct +/// T: Merge, // this clause was specified using #[merge(bounds)] +/// { +/// fn merge(&mut self, defaults: &Self) { +/// self.inner.merge(&defaults.inner); +/// } +/// } +/// ``` +/// +/// ## `#[merge(path_overrides(merge = "..."))]` +/// +/// This attribute can be used to override the path to the module containing the [`Merge`](trait.Merge.html) trait, if it is reexported +/// or the `stackable_operator` crate is renamed. +#[proc_macro_derive(Merge, attributes(merge))] +pub fn derive_merge(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let MergeInput { + ident, + mut generics, + data, + path_overrides: PathOverrides { merge: merge_mod }, + bounds, + } = match MergeInput::from_derive_input(&parse_macro_input!(input)) { + Ok(input) => input, + Err(err) => return err.write_errors().into(), + }; + + let (ty, variants) = match data { + // Structs are almost single-variant enums, so we can reuse most of the same matching code for both cases + Data::Struct(fields) => ( + InputType::Struct, + vec![MergeVariant { + ident: Ident::new("__placeholder", Span::call_site()), + fields, + }], + ), + Data::Enum(variants) => (InputType::Enum, variants), + }; + let merge_variants = variants + .into_iter() + .map( + |MergeVariant { + ident: variant_ident, + fields, + }| { + let constructor: Path = match ty { + InputType::Struct => parse_quote! {#ident}, + InputType::Enum => parse_quote! {#ident::#variant_ident}, + }; + let self_ident = format_ident!("self"); + let defaults_ident = format_ident!("defaults"); + let field_idents = fields.iter().map(|f| f.ident.as_ref()); + let self_fields = + map_fields_to_prefixed_vars(&constructor, field_idents.clone(), &self_ident); + let defaults_fields = + map_fields_to_prefixed_vars(&constructor, field_idents, &defaults_ident); + let body = fields + .into_iter() + .enumerate() + .map(|(field_index, field)| { + let field_ident = field.ident.as_ref().ok_or(field_index); + let self_field = prefix_ident(field_ident, &self_ident); + let default_field = prefix_ident(field_ident, &defaults_ident); + quote! { + #merge_mod::Merge::merge(#self_field, #default_field); + } + }) + .collect::(); + + let pattern = match ty { + InputType::Struct => quote! {(#self_fields, #defaults_fields)}, + InputType::Enum => quote! {(Some(#self_fields), Some(#defaults_fields))}, + }; + quote! { + #pattern => {#body}, + } + }, + ) + .collect::(); + + if let Some(bounds) = bounds { + let where_clause = generics.make_where_clause(); + where_clause.predicates.extend(bounds); + } + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let ty_toks = match ty { + InputType::Struct => quote! { #ident #ty_generics }, + // Enums need some way to indicate that we want to keep the same variant, in our case we use + // Option::None to signal this + InputType::Enum => quote! { Option<#ident #ty_generics> }, + }; + let fallback_variants = match ty { + InputType::Struct => quote! {}, + InputType::Enum => quote! { + // self is None => inherit everything from defaults + (this @ None, defaults) => *this = ::clone(defaults), + // self is Some but mismatches defaults, discard defaults + (Some(_), _) => {} + }, + }; + quote! { + impl #impl_generics #merge_mod::Merge for #ty_toks #where_clause { + fn merge(&mut self, defaults: &Self) { + match (self, defaults) { + #merge_variants + #fallback_variants + } + } + } + } + .into() +} + +fn map_fields_to_prefixed_vars<'a>( + constructor: &Path, + fields: impl IntoIterator>, + prefix: &Ident, +) -> TokenStream { + let fields = fields + .into_iter() + .enumerate() + .map(|(index, field)| { + let prefixed = prefix_ident(field.ok_or(index), prefix); + if let Some(field) = field { + quote! { #field: #prefixed, } + } else { + let index = Index::from(index); + quote! { #index: #prefixed, } + } + }) + .collect::(); + quote! { #constructor { #fields } } +} + +fn prefix_ident(ident: Result<&Ident, usize>, prefix: &Ident) -> Ident { + match ident { + Ok(ident) => format_ident!("{prefix}_{ident}"), + Err(index) => format_ident!("{prefix}_{index}"), + } +}