diff --git a/Cargo.lock b/Cargo.lock index 0a97a055..bbc51a75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2994,10 +2994,16 @@ dependencies = [ "convert_case", "darling", "itertools 0.13.0", + "k8s-openapi", "k8s-version", + "kube", "proc-macro2", "quote", "rstest", + "schemars", + "serde", + "serde_json", + "serde_yaml", "strum", "syn 2.0.74", "trybuild", diff --git a/crates/stackable-versioned-macros/Cargo.toml b/crates/stackable-versioned-macros/Cargo.toml index e58943ec..4bae52b5 100644 --- a/crates/stackable-versioned-macros/Cargo.toml +++ b/crates/stackable-versioned-macros/Cargo.toml @@ -9,12 +9,18 @@ repository.workspace = true [lib] proc-macro = true +[features] +full = ["k8s"] +k8s = ["dep:kube", "dep:k8s-openapi"] + [dependencies] k8s-version = { path = "../k8s-version", features = ["darling"] } convert_case.workspace = true darling.workspace = true itertools.workspace = true +k8s-openapi = { workspace = true, optional = true } +kube = { workspace = true, optional = true } proc-macro2.workspace = true strum.workspace = true syn.workspace = true @@ -22,4 +28,8 @@ quote.workspace = true [dev-dependencies] rstest.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true trybuild.workspace = true diff --git a/crates/stackable-versioned-macros/src/attrs/common/container.rs b/crates/stackable-versioned-macros/src/attrs/common/container.rs index 5888d45c..41f21559 100644 --- a/crates/stackable-versioned-macros/src/attrs/common/container.rs +++ b/crates/stackable-versioned-macros/src/attrs/common/container.rs @@ -19,8 +19,11 @@ pub(crate) struct ContainerAttributes { #[darling(multiple, rename = "version")] pub(crate) versions: SpannedValue>, - #[darling(default)] - pub(crate) options: ContainerOptions, + #[darling(rename = "k8s")] + pub(crate) kubernetes_attrs: Option, + + #[darling(default, rename = "options")] + pub(crate) common_option_attrs: OptionAttributes, } impl ContainerAttributes { @@ -43,7 +46,7 @@ impl ContainerAttributes { // Ensure that versions are defined in sorted (ascending) order to keep // code consistent. - if !self.options.allow_unsorted.is_present() { + if !self.common_option_attrs.allow_unsorted.is_present() { let original = self.versions.deref().clone(); self.versions .sort_by(|lhs, rhs| lhs.name.partial_cmp(&rhs.name).unwrap_or(Ordering::Equal)); @@ -71,20 +74,28 @@ impl ContainerAttributes { // place. // Ensure every version is unique and isn't declared multiple times. - let duplicates = self + let duplicate_versions = self .versions .iter() .duplicates_by(|e| e.name) .map(|e| e.name) .join(", "); - if !duplicates.is_empty() { + if !duplicate_versions.is_empty() { return Err(Error::custom(format!( - "attribute macro `#[versioned()]` contains duplicate versions: {duplicates}", + "attribute macro `#[versioned()]` contains duplicate versions: {duplicate_versions}", )) .with_span(&self.versions.span())); } + // Ensure that the 'k8s' feature is enabled when the 'k8s()' + // attribute is used. + if self.kubernetes_attrs.is_some() && cfg!(not(feature = "k8s")) { + return Err(Error::custom( + "the `#[versioned(k8s())]` attribute can only be used when the `k8s` feature is enabled", + )); + } + Ok(self) } } @@ -101,29 +112,50 @@ impl ContainerAttributes { pub(crate) struct VersionAttributes { pub(crate) deprecated: Flag, pub(crate) name: Version, - pub(crate) skip: Option, + pub(crate) skip: Option, pub(crate) doc: Option, } -/// This struct contains supported container options. +/// This struct contains supported option attributes. /// -/// Supported options are: +/// Supported attributes are: /// /// - `allow_unsorted`, which allows declaring versions in unsorted order, /// instead of enforcing ascending order. /// - `skip` option to skip generating various pieces of code. #[derive(Clone, Debug, Default, FromMeta)] -pub(crate) struct ContainerOptions { +pub(crate) struct OptionAttributes { pub(crate) allow_unsorted: Flag, - pub(crate) skip: Option, + pub(crate) skip: Option, } -/// This struct contains supported skip options. +/// This struct contains supported Kubernetes attributes. /// -/// Supported options are: +/// Supported attributes are: +/// +/// - `kind`, which allows overwriting the kind field of the CRD. This defaults +/// to the struct name (without the 'Spec' suffix). +/// - `group`, which sets the CRD group, usually the domain of the company. +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct KubernetesAttributes { + pub(crate) skip: Option, + pub(crate) kind: Option, + pub(crate) group: String, +} + +#[derive(Clone, Debug, FromMeta)] +pub(crate) struct KubernetesSkipAttributes { + pub(crate) merged_crd: Flag, +} + +/// This struct contains supported common skip attributes. +/// +/// Supported attributes are: /// /// - `from` flag, which skips generating [`From`] implementations when provided. #[derive(Clone, Debug, Default, FromMeta)] -pub(crate) struct SkipOptions { +pub(crate) struct CommonSkipAttributes { + /// Whether the [`From`] implementation generation should be skipped for all + /// versions of this container. pub(crate) from: Flag, } diff --git a/crates/stackable-versioned-macros/src/codegen/common/container.rs b/crates/stackable-versioned-macros/src/codegen/common/container.rs index 1ab77e7c..2331238a 100644 --- a/crates/stackable-versioned-macros/src/codegen/common/container.rs +++ b/crates/stackable-versioned-macros/src/codegen/common/container.rs @@ -1,6 +1,7 @@ use std::ops::Deref; use proc_macro2::TokenStream; +use quote::format_ident; use syn::{Attribute, Ident, Visibility}; use crate::{attrs::common::ContainerAttributes, codegen::common::ContainerVersion}; @@ -32,6 +33,25 @@ where fn generate_tokens(&self) -> TokenStream; } +/// Provides extra functionality on top of [`Ident`]s. +pub(crate) trait IdentExt { + /// Removes the 'Spec' suffix from the [`Ident`]. + fn as_cleaned_kubernetes_ident(&self) -> Ident; + + /// Transforms the [`Ident`] into one usable in the [`From`] impl. + fn as_from_impl_ident(&self) -> Ident; +} + +impl IdentExt for Ident { + fn as_cleaned_kubernetes_ident(&self) -> Ident { + format_ident!("{}", self.to_string().trim_end_matches("Spec")) + } + + fn as_from_impl_ident(&self) -> Ident { + format_ident!("__sv_{}", self.to_string().to_lowercase()) + } +} + /// This struct bundles values from [`DeriveInput`][1]. /// /// [`DeriveInput`][1] cannot be used directly when constructing a @@ -58,24 +78,95 @@ pub(crate) struct VersionedContainer { /// definition with appropriate items. pub(crate) versions: Vec, + /// The original attributes that were added to the container. + pub(crate) original_attributes: Vec, + + /// The visibility of the versioned container. Used to forward the + /// visibility during code generation. + pub(crate) visibility: Visibility, + /// List of items defined in the original container. How, and if, an item /// should generate code, is decided by the currently generated version. pub(crate) items: Vec, - /// The ident, or name, of the versioned container. - pub(crate) ident: Ident, + /// Different options which influence code generation. + pub(crate) options: VersionedContainerOptions, - /// The visibility of the versioned container. Used to forward the - /// visibility during code generation. - pub(crate) visibility: Visibility, + /// A collection of container idents used for different purposes. + pub(crate) idents: VersionedContainerIdents, +} - /// The original attributes that were added to the container. - pub(crate) original_attributes: Vec, +impl VersionedContainer { + /// Creates a new versioned Container which contains common data shared + /// across structs and enums. + pub(crate) fn new( + input: ContainerInput, + attributes: ContainerAttributes, + versions: Vec, + items: Vec, + ) -> Self { + let ContainerInput { + original_attributes, + visibility, + ident, + } = input; + + let skip_from = attributes + .common_option_attrs + .skip + .map_or(false, |s| s.from.is_present()); + + let kubernetes_options = attributes.kubernetes_attrs.map(|a| KubernetesOptions { + skip_merged_crd: a.skip.map_or(false, |s| s.merged_crd.is_present()), + group: a.group, + kind: a.kind, + }); - /// The name of the container used in `From` implementations. - pub(crate) from_ident: Ident, + let options = VersionedContainerOptions { + kubernetes_options, + skip_from, + }; - /// Whether the [`From`] implementation generation should be skipped for all - /// versions of this container. + let idents = VersionedContainerIdents { + kubernetes: ident.as_cleaned_kubernetes_ident(), + from: ident.as_from_impl_ident(), + original: ident, + }; + + VersionedContainer { + original_attributes, + visibility, + versions, + options, + idents, + items, + } + } +} + +/// A collection of container idents used for different purposes. +#[derive(Debug)] +pub(crate) struct VersionedContainerIdents { + /// The ident used in the context of Kubernetes specific code. This ident + /// removes the 'Spec' suffix present in the definition container. + pub(crate) kubernetes: Ident, + + /// The original ident, or name, of the versioned container. + pub(crate) original: Ident, + + /// The ident used in the [`From`] impl. + pub(crate) from: Ident, +} + +#[derive(Debug)] +pub(crate) struct VersionedContainerOptions { + pub(crate) kubernetes_options: Option, pub(crate) skip_from: bool, } + +#[derive(Debug)] +pub(crate) struct KubernetesOptions { + pub(crate) skip_merged_crd: bool, + pub(crate) kind: Option, + pub(crate) group: String, +} diff --git a/crates/stackable-versioned-macros/src/codegen/common/item.rs b/crates/stackable-versioned-macros/src/codegen/common/item.rs index 65c0b1f9..f97a1431 100644 --- a/crates/stackable-versioned-macros/src/codegen/common/item.rs +++ b/crates/stackable-versioned-macros/src/codegen/common/item.rs @@ -207,7 +207,6 @@ where let mut actions = BTreeMap::new(); for change in common_attributes.changes.iter().rev() { - dbg!(&ty, &change.since); let from_ident = if let Some(from) = change.from_name.as_deref() { format_ident!("{from}") } else { diff --git a/crates/stackable-versioned-macros/src/codegen/common/mod.rs b/crates/stackable-versioned-macros/src/codegen/common/mod.rs index ad35ae1f..d46cfd16 100644 --- a/crates/stackable-versioned-macros/src/codegen/common/mod.rs +++ b/crates/stackable-versioned-macros/src/codegen/common/mod.rs @@ -70,11 +70,6 @@ impl From<&ContainerAttributes> for Vec { } } -/// Returns the container ident used in [`From`] implementations. -pub(crate) fn format_container_from_ident(ident: &Ident) -> Ident { - format_ident!("__sv_{ident}", ident = ident.to_string().to_lowercase()) -} - /// Removes the deprecated prefix from a field ident. /// /// See [`DEPRECATED_FIELD_PREFIX`]. diff --git a/crates/stackable-versioned-macros/src/codegen/venum/mod.rs b/crates/stackable-versioned-macros/src/codegen/venum/mod.rs index a9aefb0c..d6b691af 100644 --- a/crates/stackable-versioned-macros/src/codegen/venum/mod.rs +++ b/crates/stackable-versioned-macros/src/codegen/venum/mod.rs @@ -8,10 +8,7 @@ use syn::{DataEnum, Error}; use crate::{ attrs::common::ContainerAttributes, codegen::{ - common::{ - format_container_from_ident, Container, ContainerInput, ContainerVersion, Item, - VersionedContainer, - }, + common::{Container, ContainerInput, ContainerVersion, Item, VersionedContainer}, venum::variant::VersionedVariant, }, }; @@ -39,11 +36,7 @@ impl Container for VersionedEnum { data: DataEnum, attributes: ContainerAttributes, ) -> syn::Result { - let ContainerInput { - original_attributes, - visibility, - ident, - } = input; + let ident = &input.ident; // Convert the raw version attributes into a container version. let versions: Vec<_> = (&attributes).into(); @@ -77,20 +70,9 @@ impl Container for VersionedEnum { } } - let from_ident = format_container_from_ident(&ident); - - Ok(Self(VersionedContainer { - skip_from: attributes - .options - .skip - .map_or(false, |s| s.from.is_present()), - original_attributes, - visibility, - from_ident, - versions, - items, - ident, - })) + Ok(Self(VersionedContainer::new( + input, attributes, versions, items, + ))) } fn generate_tokens(&self) -> TokenStream { @@ -114,8 +96,8 @@ impl VersionedEnum { let mut token_stream = TokenStream::new(); let original_attributes = &self.original_attributes; + let enum_name = &self.idents.original; let visibility = &self.visibility; - let enum_name = &self.ident; // Generate variants of the enum for `version`. let variants = self.generate_enum_variants(version); @@ -131,19 +113,8 @@ impl VersionedEnum { .deprecated .then_some(quote! {#[deprecated = #deprecated_note]}); - let mut version_specific_docs = TokenStream::new(); - for (i, doc) in version.version_specific_docs.iter().enumerate() { - if i == 0 { - // Prepend an empty line to clearly separate the version - // specific docs. - version_specific_docs.extend(quote! { - #[doc = ""] - }) - } - version_specific_docs.extend(quote! { - #[doc = #doc] - }) - } + // Generate doc comments for the container (enum) + let version_specific_docs = self.generate_enum_docs(version); // Generate tokens for the module and the contained enum token_stream.extend(quote! { @@ -152,8 +123,8 @@ impl VersionedEnum { #visibility mod #version_ident { use super::*; - #(#original_attributes)* #version_specific_docs + #(#original_attributes)* pub enum #enum_name { #variants } @@ -161,13 +132,33 @@ impl VersionedEnum { }); // Generate the From impl between this `version` and the next one. - if !self.skip_from && !version.skip_from { + if !self.options.skip_from && !version.skip_from { token_stream.extend(self.generate_from_impl(version, next_version)); } token_stream } + /// Generates version specific doc comments for the enum. + fn generate_enum_docs(&self, version: &ContainerVersion) -> TokenStream { + let mut tokens = TokenStream::new(); + + for (i, doc) in version.version_specific_docs.iter().enumerate() { + if i == 0 { + // Prepend an empty line to clearly separate the version + // specific docs. + tokens.extend(quote! { + #[doc = ""] + }) + } + tokens.extend(quote! { + #[doc = #doc] + }) + } + + tokens + } + fn generate_enum_variants(&self, version: &ContainerVersion) -> TokenStream { let mut token_stream = TokenStream::new(); @@ -187,8 +178,8 @@ impl VersionedEnum { let next_module_name = &next_version.ident; let module_name = &version.ident; - let from_ident = &self.from_ident; - let enum_ident = &self.ident; + let enum_ident = &self.idents.original; + let from_ident = &self.idents.from; let mut variants = TokenStream::new(); diff --git a/crates/stackable-versioned-macros/src/codegen/vstruct/mod.rs b/crates/stackable-versioned-macros/src/codegen/vstruct/mod.rs index f4c257e5..6dd49390 100644 --- a/crates/stackable-versioned-macros/src/codegen/vstruct/mod.rs +++ b/crates/stackable-versioned-macros/src/codegen/vstruct/mod.rs @@ -3,15 +3,12 @@ use std::ops::Deref; use itertools::Itertools; use proc_macro2::TokenStream; use quote::quote; -use syn::{DataStruct, Error, Ident}; +use syn::{parse_quote, DataStruct, Error, Ident}; use crate::{ attrs::common::ContainerAttributes, codegen::{ - common::{ - format_container_from_ident, Container, ContainerInput, ContainerVersion, Item, - VersionedContainer, - }, + common::{Container, ContainerInput, ContainerVersion, Item, VersionedContainer}, vstruct::field::VersionedField, }, }; @@ -39,11 +36,7 @@ impl Container for VersionedStruct { data: DataStruct, attributes: ContainerAttributes, ) -> syn::Result { - let ContainerInput { - original_attributes, - visibility, - ident, - } = input; + let ident = &input.ident; // Convert the raw version attributes into a container version. let versions: Vec<_> = (&attributes).into(); @@ -77,35 +70,44 @@ impl Container for VersionedStruct { } } - let from_ident = format_container_from_ident(&ident); - - Ok(Self(VersionedContainer { - skip_from: attributes - .options - .skip - .map_or(false, |s| s.from.is_present()), - original_attributes, - visibility, - from_ident, - versions, - items, - ident, - })) + // Validate K8s specific requirements + // Ensure that the struct name includes the 'Spec' suffix. + if attributes.kubernetes_attrs.is_some() && !ident.to_string().ends_with("Spec") { + return Err(Error::new( + ident.span(), + "struct name needs to include the `Spec` suffix if Kubernetes features are enabled via `#[versioned(k8s())]`" + )); + } + + Ok(Self(VersionedContainer::new( + input, attributes, versions, items, + ))) } fn generate_tokens(&self) -> TokenStream { - let mut token_stream = TokenStream::new(); + let mut kubernetes_crd_fn_calls = TokenStream::new(); + let mut container_definition = TokenStream::new(); + let mut versions = self.versions.iter().peekable(); while let Some(version) = versions.next() { - token_stream.extend(self.generate_version(version, versions.peek().copied())); + container_definition.extend(self.generate_version(version, versions.peek().copied())); + kubernetes_crd_fn_calls.extend(self.generate_kubernetes_crd_fn_call(version)); } - token_stream + // If tokens for the 'crd()' function calls were generated, also generate + // the 'merge_crds' call. + if !kubernetes_crd_fn_calls.is_empty() { + container_definition + .extend(self.generate_kubernetes_merge_crds(kubernetes_crd_fn_calls)); + } + + container_definition } } impl VersionedStruct { + /// Generates all tokens for a single instance of a versioned struct. fn generate_version( &self, version: &ContainerVersion, @@ -114,8 +116,8 @@ impl VersionedStruct { let mut token_stream = TokenStream::new(); let original_attributes = &self.original_attributes; + let struct_name = &self.idents.original; let visibility = &self.visibility; - let struct_name = &self.ident; // Generate fields of the struct for `version`. let fields = self.generate_struct_fields(version); @@ -131,19 +133,11 @@ impl VersionedStruct { .deprecated .then_some(quote! {#[deprecated = #deprecated_note]}); - let mut version_specific_docs = TokenStream::new(); - for (i, doc) in version.version_specific_docs.iter().enumerate() { - if i == 0 { - // Prepend an empty line to clearly separate the version - // specific docs. - version_specific_docs.extend(quote! { - #[doc = ""] - }) - } - version_specific_docs.extend(quote! { - #[doc = #doc] - }) - } + // Generate doc comments for the container (struct) + let version_specific_docs = self.generate_struct_docs(version); + + // Generate K8s specific code + let kubernetes_cr_derive = self.generate_kubernetes_cr_derive(version); // Generate tokens for the module and the contained struct token_stream.extend(quote! { @@ -152,8 +146,9 @@ impl VersionedStruct { #visibility mod #version_ident { use super::*; - #(#original_attributes)* #version_specific_docs + #(#original_attributes)* + #kubernetes_cr_derive pub struct #struct_name { #fields } @@ -161,40 +156,64 @@ impl VersionedStruct { }); // Generate the From impl between this `version` and the next one. - if !self.skip_from && !version.skip_from { + if !self.options.skip_from && !version.skip_from { token_stream.extend(self.generate_from_impl(version, next_version)); } token_stream } + /// Generates version specific doc comments for the struct. + fn generate_struct_docs(&self, version: &ContainerVersion) -> TokenStream { + let mut tokens = TokenStream::new(); + + for (i, doc) in version.version_specific_docs.iter().enumerate() { + if i == 0 { + // Prepend an empty line to clearly separate the version + // specific docs. + tokens.extend(quote! { + #[doc = ""] + }) + } + tokens.extend(quote! { + #[doc = #doc] + }) + } + + tokens + } + + /// Generates struct fields following the `name: type` format which includes + /// a trailing comma. fn generate_struct_fields(&self, version: &ContainerVersion) -> TokenStream { - let mut token_stream = TokenStream::new(); + let mut tokens = TokenStream::new(); for item in &self.items { - token_stream.extend(item.generate_for_container(version)); + tokens.extend(item.generate_for_container(version)); } - token_stream + tokens } + /// Generates the [`From`] impl which enables conversion between a version + /// and the next one. fn generate_from_impl( &self, version: &ContainerVersion, next_version: Option<&ContainerVersion>, - ) -> TokenStream { + ) -> Option { if let Some(next_version) = next_version { let next_module_name = &next_version.ident; let module_name = &version.ident; - let from_ident = &self.from_ident; - let struct_ident = &self.ident; + let struct_ident = &self.idents.original; + let from_ident = &self.idents.from; let fields = self.generate_from_fields(version, next_version, from_ident); // TODO (@Techassi): Be a little bit more clever about when to include // the #[allow(deprecated)] attribute. - return quote! { + return Some(quote! { #[automatically_derived] #[allow(deprecated)] impl From<#module_name::#struct_ident> for #next_module_name::#struct_ident { @@ -204,12 +223,14 @@ impl VersionedStruct { } } } - }; + }); } - quote! {} + None } + /// Generates fields used in the [`From`] impl following the + /// `new_name: struct_name.old_name` format which includes a trailing comma. fn generate_from_fields( &self, version: &ContainerVersion, @@ -225,3 +246,68 @@ impl VersionedStruct { token_stream } } + +// Kubernetes specific code generation +impl VersionedStruct { + /// Generates the `kube::CustomResource` derive with the appropriate macro + /// attributes. + fn generate_kubernetes_cr_derive(&self, version: &ContainerVersion) -> Option { + if let Some(kubernetes_options) = &self.options.kubernetes_options { + let group = &kubernetes_options.group; + let version = version.inner.to_string(); + let kind = kubernetes_options + .kind + .as_ref() + .map_or(self.idents.kubernetes.to_string(), |kind| kind.clone()); + + return Some(quote! { + #[derive(::kube::CustomResource)] + #[kube(group = #group, version = #version, kind = #kind)] + }); + } + + None + } + + /// Generates the `merge_crds` function call. + fn generate_kubernetes_merge_crds(&self, fn_calls: TokenStream) -> TokenStream { + let ident = &self.idents.kubernetes; + + quote! { + #[automatically_derived] + pub struct #ident; + + #[automatically_derived] + impl #ident { + /// Generates a merged CRD which contains all versions defined using the + /// `#[versioned()]` macro. + pub fn merged_crd( + stored_apiversion: &str + ) -> ::std::result::Result<::k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition, ::kube::core::crd::MergeError> { + ::kube::core::crd::merge_crds(vec![#fn_calls], stored_apiversion) + } + } + } + } + + /// Generates the inner `crd()` functions calls which get used in the + /// `merge_crds` function. + fn generate_kubernetes_crd_fn_call(&self, version: &ContainerVersion) -> Option { + if self + .options + .kubernetes_options + .as_ref() + .is_some_and(|o| !o.skip_merged_crd) + { + let struct_ident = &self.idents.kubernetes; + let version_ident = &version.ident; + + let path: syn::Path = parse_quote!(#version_ident::#struct_ident); + return Some(quote! { + <#path as ::kube::CustomResourceExt>::crd(), + }); + } + + None + } +} diff --git a/crates/stackable-versioned-macros/tests/crd.rs b/crates/stackable-versioned-macros/tests/crd.rs new file mode 100644 index 00000000..da030662 --- /dev/null +++ b/crates/stackable-versioned-macros/tests/crd.rs @@ -0,0 +1,32 @@ +#[cfg(feature = "k8s")] +use schemars::JsonSchema; + +#[cfg(feature = "k8s")] +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "k8s")] +use stackable_versioned_macros::versioned; + +#[cfg(feature = "k8s")] +#[allow(deprecated)] +#[test] +fn crd() { + #[versioned( + version(name = "v1alpha1"), + version(name = "v1beta1"), + version(name = "v1"), + k8s(group = "stackable.tech") + )] + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub struct FooSpec { + #[versioned( + added(since = "v1beta1"), + changed(since = "v1", from_name = "bah", from_type = "u16") + )] + bar: usize, + baz: bool, + } + + let merged_crd = Foo::merged_crd("v1").unwrap(); + println!("{}", serde_yaml::to_string(&merged_crd).unwrap()); +}