diff --git a/crates/stackable-versioned-macros/src/attrs/item/variant.rs b/crates/stackable-versioned-macros/src/attrs/item/variant.rs index 46d1c69fe..06cd4b3ed 100644 --- a/crates/stackable-versioned-macros/src/attrs/item/variant.rs +++ b/crates/stackable-versioned-macros/src/attrs/item/variant.rs @@ -1,5 +1,5 @@ use convert_case::{Case, Casing}; -use darling::{Error, FromVariant, Result}; +use darling::{Error, FromVariant, Result, util::Flag}; use syn::{Attribute, Ident}; use crate::{ @@ -40,6 +40,11 @@ pub struct VariantAttributes { // FromMeta. /// The original attributes for the field. pub attrs: Vec, + + /// Indicates that the data of this variant contains nested sub structs or enums. The + /// indicator is needed to let the macro know to generate conversion code with support for + /// tracking across container boundaries. + pub nested: Flag, } impl VariantAttributes { @@ -73,6 +78,17 @@ impl VariantAttributes { pub fn validate_versions(&self, versions: &[VersionDefinition]) -> Result<()> { self.common.validate_versions(versions) } + + pub fn validate_nested_flag(&self, experimental_conversion_tracking: bool) -> Result<()> { + if self.nested.is_present() && !experimental_conversion_tracking { + return Err( + Error::custom("the `nested` argument can only be used if the module-level `experimental_conversion_tracking` flag is set") + .with_span(&self.nested.span()) + ); + } + + Ok(()) + } } // We don't want to take a dependency on the (unmaintained[1]) `convert_case_extras` crate just for diff --git a/crates/stackable-versioned-macros/src/codegen/container/enum.rs b/crates/stackable-versioned-macros/src/codegen/container/enum.rs index fbe5b40b7..1730b3207 100644 --- a/crates/stackable-versioned-macros/src/codegen/container/enum.rs +++ b/crates/stackable-versioned-macros/src/codegen/container/enum.rs @@ -3,7 +3,7 @@ use std::ops::Not; use darling::{FromAttributes, Result}; use proc_macro2::TokenStream; use quote::quote; -use syn::{Generics, ItemEnum}; +use syn::{Generics, ItemEnum, parse_quote}; use crate::{ attrs::container::ContainerAttributes, @@ -19,12 +19,17 @@ use crate::{ }; impl Container { - pub fn new_enum(item_enum: ItemEnum, versions: &[VersionDefinition]) -> Result { + pub fn new_enum( + item_enum: ItemEnum, + versions: &[VersionDefinition], + experimental_conversion_tracking: bool, + ) -> Result { let attributes = ContainerAttributes::from_attributes(&item_enum.attrs)?; let mut versioned_variants = Vec::new(); for variant in item_enum.variants { - let mut versioned_variant = VersionedVariant::new(variant, versions)?; + let mut versioned_variant = + VersionedVariant::new(variant, versions, experimental_conversion_tracking)?; versioned_variant.insert_container_versions(versions); versioned_variants.push(versioned_variant); } @@ -157,7 +162,7 @@ impl Enum { self.variants .iter() .filter_map(|v| { - v.generate_for_from_impl(direction, version, next_version, enum_ident) + v.generate_for_from_impl(direction, version, next_version, enum_ident, gen_ctx) }) .collect() }; @@ -177,6 +182,38 @@ impl Enum { }, }; + // With conversion tracking enabled, the enum needs to forward the status and the + // current path to the data of nested variants. As such, only a TrackingFrom impl is + // generated, which is in line with the impls generated for structs. + if gen_ctx.kubernetes_options.experimental_conversion_tracking.is_present() { + let versioned_path = &*gen_ctx.crates.versioned; + + let mut tracking_generics = self.generics.clone(); + tracking_generics.params.push(parse_quote! { S }); + tracking_generics + .make_where_clause() + .predicates + .push(parse_quote! { S: #versioned_path::TrackingStatus + ::core::default::Default }); + let (impl_generics, _, where_clause) = tracking_generics.split_for_impl(); + + return quote! { + #automatically_derived + #allow_attribute + impl #impl_generics #versioned_path::TrackingFrom<#from_module_ident::#enum_ident #type_generics, S> for #for_module_ident::#enum_ident #type_generics + #where_clause + { + fn tracking_from(#from_enum_ident: #from_module_ident::#enum_ident #type_generics, status: &mut S, parent: &str) -> Self { + // TODO (@Techassi): Only emit this if any of the variants below need it + use #versioned_path::TrackingInto as _; + + match #from_enum_ident { + #variants + } + } + } + }; + } + quote! { #automatically_derived #allow_attribute diff --git a/crates/stackable-versioned-macros/src/codegen/item/variant.rs b/crates/stackable-versioned-macros/src/codegen/item/variant.rs index 6455ef6b7..11234ca9d 100644 --- a/crates/stackable-versioned-macros/src/codegen/item/variant.rs +++ b/crates/stackable-versioned-macros/src/codegen/item/variant.rs @@ -14,6 +14,7 @@ use crate::{ Direction, VersionDefinition, changes::{BTreeMapExt, ChangesetExt}, item::{ItemStatus, generate_attributes}, + module::ModuleGenerationContext, }, utils::ItemIdents, }; @@ -24,12 +25,18 @@ pub struct VersionedVariant { pub changes: Option>, pub idents: VariantIdents, pub fields: Fields, + pub nested: bool, } impl VersionedVariant { - pub fn new(variant: Variant, versions: &[VersionDefinition]) -> Result { + pub fn new( + variant: Variant, + versions: &[VersionDefinition], + experimental_conversion_tracking: bool, + ) -> Result { let variant_attributes = VariantAttributes::from_variant(&variant)?; variant_attributes.validate_versions(versions)?; + variant_attributes.validate_nested_flag(experimental_conversion_tracking)?; let idents = VariantIdents::from(variant.ident); @@ -41,6 +48,7 @@ impl VersionedVariant { bang_token: Not([Span::call_site()]), }); let previous_docs = variant_attributes.common.previous_docs(); + let nested = variant_attributes.nested.is_present(); let changes = variant_attributes.common.into_changeset(&idents, ty); Ok(Self { @@ -49,6 +57,7 @@ impl VersionedVariant { fields: variant.fields, idents, changes, + nested, }) } @@ -152,9 +161,10 @@ impl VersionedVariant { version: &VersionDefinition, next_version: &VersionDefinition, enum_ident: &IdentString, + mod_gen_ctx: ModuleGenerationContext<'_>, ) -> Option { let from_fields = self.generate_from_fields(); - let for_fields = self.generate_for_fields(); + let for_fields = self.generate_for_fields(mod_gen_ctx); #[allow(clippy::single_match_else)] match &self.changes { @@ -203,20 +213,55 @@ impl VersionedVariant { } } - fn generate_for_fields(&self) -> Option { + fn generate_for_fields(&self, mod_gen_ctx: ModuleGenerationContext<'_>) -> Option { match &self.fields { Fields::Named(fields_named) => { let fields = Self::named_field_idents(fields_named); - Some(quote! { { #(#fields: #fields.into(),)* } }) + let conversions = fields.iter().map(|field| { + self.generate_conversion_function(Some(&field.to_string()), mod_gen_ctx) + }); + + Some(quote! { { #(#fields: #fields.#conversions,)* } }) } Fields::Unnamed(fields_unnamed) => { let fields = Self::unnamed_field_ident(fields_unnamed); - Some(quote! { ( #(#fields.into())* ) }) + + // Newtype variants (which are the most common variants with data) don't need an + // additional path segment, as the variant only contains a single field. + let conversions = (0..fields.len()).map(|index| { + let child = (fields.len() > 1).then(|| index.to_string()); + self.generate_conversion_function(child.as_deref(), mod_gen_ctx) + }); + + Some(quote! { ( #(#fields.#conversions),* ) }) } Fields::Unit => None, } } + /// Generates the conversion function for a single field of the variant data. + /// + /// The data of variants marked as nested is converted with support for tracking. The path + /// passed down consists of the variant name and the provided `child`, if any. + fn generate_conversion_function( + &self, + child: Option<&str>, + mod_gen_ctx: ModuleGenerationContext<'_>, + ) -> TokenStream { + if !self.nested { + return quote! { into() }; + } + + let versioned_path = &*mod_gen_ctx.crates.versioned; + let variant = &self.idents.original; + let child_string = match child { + Some(child) => format!("{variant}.{child}"), + None => variant.to_string(), + }; + + quote! { tracking_into(status, &#versioned_path::jthong_path(parent, #child_string)) } + } + fn generate_from_fields(&self) -> Option { match &self.fields { Fields::Named(fields_named) => { @@ -225,7 +270,7 @@ impl VersionedVariant { } Fields::Unnamed(fields_unnamed) => { let fields = Self::unnamed_field_ident(fields_unnamed); - Some(quote! { ( #(#fields)* ) }) + Some(quote! { ( #(#fields),* ) }) } Fields::Unit => None, } diff --git a/crates/stackable-versioned-macros/src/codegen/module.rs b/crates/stackable-versioned-macros/src/codegen/module.rs index 681321850..c76e85e42 100644 --- a/crates/stackable-versioned-macros/src/codegen/module.rs +++ b/crates/stackable-versioned-macros/src/codegen/module.rs @@ -50,22 +50,24 @@ impl Module { let mut submodules = HashMap::new(); let mut containers = Vec::new(); + let experimental_conversion_tracking = module_attributes + .options + .kubernetes + .experimental_conversion_tracking + .is_present(); + for item in items { match item { Item::Enum(item_enum) => { - if let Some(container) = - errors.handle(Container::new_enum(item_enum, &versions)) - { + if let Some(container) = errors.handle(Container::new_enum( + item_enum, + &versions, + experimental_conversion_tracking, + )) { containers.push(container); } } Item::Struct(item_struct) => { - let experimental_conversion_tracking = module_attributes - .options - .kubernetes - .experimental_conversion_tracking - .is_present(); - if let Some(container) = errors.handle(Container::new_struct( item_struct, &versions, diff --git a/crates/stackable-versioned-macros/src/lib.rs b/crates/stackable-versioned-macros/src/lib.rs index 9859ea734..e688ea5d8 100644 --- a/crates/stackable-versioned-macros/src/lib.rs +++ b/crates/stackable-versioned-macros/src/lib.rs @@ -969,6 +969,50 @@ mod utils; /// # fn main() {} /// ``` /// +/// Changes can also be tracked through enums. With conversion tracking enabled, +/// versioned structs and enums only implement `TrackingFrom` instead of `From`. +/// As such, every field and every enum variant containing a versioned struct +/// or enum needs to be marked with `#[versioned(nested)]`. All data of a nested +/// variant is converted with tracking support. The path of tracked values +/// contains the variant name, eg. `$.connector.Baz.quox`. +/// +/// ``` +/// # use stackable_versioned_macros::versioned; +/// # use kube::CustomResource; +/// # use schemars::JsonSchema; +/// # use serde::{Deserialize, Serialize}; +/// #[versioned( +/// version(name = "v1alpha1"), +/// version(name = "v1beta1"), +/// options(k8s(experimental_conversion_tracking)) +/// )] +/// mod versioned { +/// #[versioned(crd(group = "example.com", doc = "My custom resource."))] +/// #[derive(Clone, Debug, Deserialize, Serialize, CustomResource, JsonSchema)] +/// struct FooSpec { +/// #[versioned(nested)] +/// connector: Connector, +/// } +/// +/// #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +/// enum Connector { +/// #[versioned(nested)] +/// Baz(Baz), +/// +/// Unit, +/// } +/// +/// #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +/// struct Baz { +/// quax: String, +/// +/// #[versioned(added(since = "v1beta1"))] +/// quox: bool, +/// } +/// } +/// # fn main() {} +/// ``` +/// /// # OpenTelemetry Semantic Conventions /// /// If tracing is enabled, various traces and events are emitted. The fields of diff --git a/crates/stackable-versioned-macros/tests/inputs/pass/conversion_tracking_enum.rs b/crates/stackable-versioned-macros/tests/inputs/pass/conversion_tracking_enum.rs new file mode 100644 index 000000000..428ee98d4 --- /dev/null +++ b/crates/stackable-versioned-macros/tests/inputs/pass/conversion_tracking_enum.rs @@ -0,0 +1,56 @@ +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; +// --- +#[versioned( + version(name = "v1alpha1"), + version(name = "v1alpha2"), + options(k8s(experimental_conversion_tracking)) +)] +// --- +pub(crate) mod versioned { + #[versioned(crd(group = "stackable.tech", doc = "Test"))] + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, CustomResource)] + pub(crate) struct FooSpec { + // With conversion tracking enabled, enums only implement TrackingFrom. As such, fields + // using a versioned enum need to be marked as nested. + #[versioned(nested)] + connector: Connector, + } + + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub(crate) enum Connector { + // The data of this variant is a versioned struct, which tracks changes. + #[versioned(nested)] + Iceberg(IcebergConnector), + + // Nested variants with named fields are supported as well. + #[versioned(nested)] + Hive { connector: IcebergConnector }, + + // Nested tuple variants with multiple fields use the index as an additional path segment. + #[versioned(nested)] + Both(IcebergConnector, IcebergConnector), + + // The data of this variant is not versioned and is converted using From. + Tpch(TpchConnector), + + Tpcds(TpchConnector, TpchConnector), + + Unit, + } + + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub(crate) struct IcebergConnector { + metastore: Option, + + #[versioned(added(since = "v1alpha2"))] + rest_catalog_uri: Option, + } +} +// --- +fn main() {} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct TpchConnector {} diff --git a/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@conversion_tracking_enum.rs.snap b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@conversion_tracking_enum.rs.snap new file mode 100644 index 000000000..40f7abe7f --- /dev/null +++ b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@conversion_tracking_enum.rs.snap @@ -0,0 +1,576 @@ +--- +source: crates/stackable-versioned-macros/src/lib.rs +expression: formatted +input_file: crates/stackable-versioned-macros/tests/inputs/pass/conversion_tracking_enum.rs +--- +#[automatically_derived] +pub(crate) mod v1alpha1 { + use super::*; + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, CustomResource)] + #[kube( + group = "stackable.tech", + version = "v1alpha1", + kind = "Foo", + doc = "Test", + status = FooStatusWithChangedValues + )] + pub struct FooSpec { + pub connector: Connector, + } + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub enum Connector { + Iceberg(IcebergConnector), + Hive { connector: IcebergConnector }, + Both(IcebergConnector, IcebergConnector), + Tpch(TpchConnector), + Tpcds(TpchConnector, TpchConnector), + Unit, + } + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub struct IcebergConnector { + pub metastore: Option, + } +} +#[automatically_derived] +impl ::core::convert::From for v1alpha2::Foo { + fn from(__sv_foo: v1alpha1::Foo) -> Self { + let mut status = __sv_foo.status.unwrap_or_default(); + let spec = >::tracking_from(__sv_foo.spec, &mut status, "$"); + Self { + metadata: __sv_foo.metadata, + status: Some(status), + spec, + } + } +} +#[automatically_derived] +impl ::core::convert::From for v1alpha1::Foo { + fn from(__sv_foo: v1alpha2::Foo) -> Self { + let mut status = __sv_foo.status.unwrap_or_default(); + let spec = >::tracking_from(__sv_foo.spec, &mut status, "$"); + Self { + metadata: __sv_foo.metadata, + status: Some(status), + spec, + } + } +} +#[automatically_derived] +impl ::stackable_versioned::TrackingFrom for v1alpha2::FooSpec +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_foospec: v1alpha1::FooSpec, + status: &mut S, + parent: &str, + ) -> Self { + use ::stackable_versioned::TrackingInto as _; + let __sv_connector_path = ::stackable_versioned::jthong_path( + parent, + "connector", + ); + let mut spec = Self { + connector: __sv_foospec.connector.tracking_into(status, &__sv_connector_path), + }; + spec + } +} +#[automatically_derived] +impl ::stackable_versioned::TrackingFrom for v1alpha1::FooSpec +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_foospec: v1alpha2::FooSpec, + status: &mut S, + parent: &str, + ) -> Self { + use ::stackable_versioned::TrackingInto as _; + let __sv_connector_path = ::stackable_versioned::jthong_path( + parent, + "connector", + ); + let mut spec = Self { + connector: __sv_foospec.connector.tracking_into(status, &__sv_connector_path), + }; + spec + } +} +#[automatically_derived] +impl ::stackable_versioned::TrackingFrom +for v1alpha2::Connector +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_connector: v1alpha1::Connector, + status: &mut S, + parent: &str, + ) -> Self { + use ::stackable_versioned::TrackingInto as _; + match __sv_connector { + v1alpha1::Connector::Iceberg(__sv_0) => { + v1alpha2::Connector::Iceberg( + __sv_0 + .tracking_into( + status, + &::stackable_versioned::jthong_path(parent, "Iceberg"), + ), + ) + } + v1alpha1::Connector::Hive { connector } => { + v1alpha2::Connector::Hive { + connector: connector + .tracking_into( + status, + &::stackable_versioned::jthong_path(parent, "Hive.connector"), + ), + } + } + v1alpha1::Connector::Both(__sv_0, __sv_1) => { + v1alpha2::Connector::Both( + __sv_0 + .tracking_into( + status, + &::stackable_versioned::jthong_path(parent, "Both.0"), + ), + __sv_1 + .tracking_into( + status, + &::stackable_versioned::jthong_path(parent, "Both.1"), + ), + ) + } + v1alpha1::Connector::Tpch(__sv_0) => v1alpha2::Connector::Tpch(__sv_0.into()), + v1alpha1::Connector::Tpcds(__sv_0, __sv_1) => { + v1alpha2::Connector::Tpcds(__sv_0.into(), __sv_1.into()) + } + v1alpha1::Connector::Unit => v1alpha2::Connector::Unit, + } + } +} +#[automatically_derived] +impl ::stackable_versioned::TrackingFrom +for v1alpha1::Connector +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_connector: v1alpha2::Connector, + status: &mut S, + parent: &str, + ) -> Self { + use ::stackable_versioned::TrackingInto as _; + match __sv_connector { + v1alpha2::Connector::Iceberg(__sv_0) => { + v1alpha1::Connector::Iceberg( + __sv_0 + .tracking_into( + status, + &::stackable_versioned::jthong_path(parent, "Iceberg"), + ), + ) + } + v1alpha2::Connector::Hive { connector } => { + v1alpha1::Connector::Hive { + connector: connector + .tracking_into( + status, + &::stackable_versioned::jthong_path(parent, "Hive.connector"), + ), + } + } + v1alpha2::Connector::Both(__sv_0, __sv_1) => { + v1alpha1::Connector::Both( + __sv_0 + .tracking_into( + status, + &::stackable_versioned::jthong_path(parent, "Both.0"), + ), + __sv_1 + .tracking_into( + status, + &::stackable_versioned::jthong_path(parent, "Both.1"), + ), + ) + } + v1alpha2::Connector::Tpch(__sv_0) => v1alpha1::Connector::Tpch(__sv_0.into()), + v1alpha2::Connector::Tpcds(__sv_0, __sv_1) => { + v1alpha1::Connector::Tpcds(__sv_0.into(), __sv_1.into()) + } + v1alpha2::Connector::Unit => v1alpha1::Connector::Unit, + } + } +} +#[automatically_derived] +impl ::stackable_versioned::TrackingFrom +for v1alpha2::IcebergConnector +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_icebergconnector: v1alpha1::IcebergConnector, + status: &mut S, + parent: &str, + ) -> Self { + use ::stackable_versioned::TrackingInto as _; + let __sv_rest_catalog_uri_path = ::stackable_versioned::jthong_path( + parent, + "rest_catalog_uri", + ); + let mut spec = Self { + metastore: __sv_icebergconnector.metastore.into(), + rest_catalog_uri: ::std::default::Default::default(), + }; + if let Some(upgrades) = status.changes().upgrades.remove(&"v1alpha2".to_owned()) + { + for ::stackable_versioned::ChangedValue { json_path, value } in upgrades { + match json_path { + json_path if json_path == __sv_rest_catalog_uri_path => { + spec.rest_catalog_uri = serde_yaml::from_value(value).unwrap(); + } + _ => unreachable!(), + } + } + } + spec + } +} +#[automatically_derived] +impl ::stackable_versioned::TrackingFrom +for v1alpha1::IcebergConnector +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_icebergconnector: v1alpha2::IcebergConnector, + status: &mut S, + parent: &str, + ) -> Self { + use ::stackable_versioned::TrackingInto as _; + let __sv_rest_catalog_uri_path = ::stackable_versioned::jthong_path( + parent, + "rest_catalog_uri", + ); + let upgrades = status + .changes() + .upgrades + .entry("v1alpha2".to_owned()) + .or_default(); + upgrades + .push(::stackable_versioned::ChangedValue { + json_path: __sv_rest_catalog_uri_path, + value: ::serde_yaml::to_value(&__sv_icebergconnector.rest_catalog_uri) + .unwrap(), + }); + let mut spec = Self { + metastore: __sv_icebergconnector.metastore.into(), + }; + spec + } +} +#[automatically_derived] +pub(crate) mod v1alpha2 { + use super::*; + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, CustomResource)] + #[kube( + group = "stackable.tech", + version = "v1alpha2", + kind = "Foo", + doc = "Test", + status = FooStatusWithChangedValues + )] + pub struct FooSpec { + pub connector: Connector, + } + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub enum Connector { + Iceberg(IcebergConnector), + Hive { connector: IcebergConnector }, + Both(IcebergConnector, IcebergConnector), + Tpch(TpchConnector), + Tpcds(TpchConnector, TpchConnector), + Unit, + } + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub struct IcebergConnector { + pub metastore: Option, + pub rest_catalog_uri: Option, + } +} +#[automatically_derived] +#[derive(::core::fmt::Debug)] +pub(crate) enum Foo { + V1Alpha1(v1alpha1::Foo), + V1Alpha2(v1alpha2::Foo), +} +#[automatically_derived] +impl Foo { + /// Generates a merged CRD containing all versions and marking `stored_apiversion` as stored. + pub fn merged_crd( + stored_apiversion: FooVersion, + ) -> ::std::result::Result< + ::k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition, + ::kube::core::crd::MergeError, + > { + ::kube::core::crd::merge_crds( + vec![ + < v1alpha1::Foo as ::kube::core::CustomResourceExt > ::crd(), < + v1alpha2::Foo as ::kube::core::CustomResourceExt > ::crd() + ], + stored_apiversion.as_version_str(), + ) + } + ///Tries to convert a list of objects of kind [`Foo`] to the desired API version + ///specified in the [`ConversionReview`][cr]. + /// + ///The returned [`ConversionReview`][cr] either indicates a success or a failure, which + ///is handed back to the Kubernetes API server. + /// + ///[cr]: ::kube::core::conversion::ConversionReview + pub fn try_convert( + review: ::kube::core::conversion::ConversionReview, + ) -> ::kube::core::conversion::ConversionReview { + let request = match ::kube::core::conversion::ConversionRequest::from_review( + review, + ) { + ::std::result::Result::Ok(request) => request, + ::std::result::Result::Err(err) => { + return ::kube::core::conversion::ConversionResponse::invalid(::kube::core::Status { + status: Some(::kube::core::response::StatusSummary::Failure), + message: err.to_string(), + metadata: None, + reason: err.to_string(), + details: None, + code: 400, + }) + .into_review(); + } + }; + let response = match Self::convert_objects( + request.objects, + &request.desired_api_version, + ) { + ::std::result::Result::Ok(converted_objects) => { + ::kube::core::conversion::ConversionResponse { + result: ::kube::core::Status::success(), + types: request.types, + uid: request.uid, + converted_objects, + } + } + ::std::result::Result::Err(err) => { + let code = err.http_status_code(); + let message = err.join_errors(); + ::kube::core::conversion::ConversionResponse { + result: ::kube::core::Status { + status: Some(::kube::core::response::StatusSummary::Failure), + message: message.clone(), + metadata: None, + reason: message, + details: None, + code, + }, + types: request.types, + uid: request.uid, + converted_objects: vec![], + } + } + }; + response.into_review() + } + fn convert_objects( + objects: ::std::vec::Vec<::serde_json::Value>, + desired_api_version: &str, + ) -> ::std::result::Result< + ::std::vec::Vec<::serde_json::Value>, + ::stackable_versioned::ConvertObjectError, + > { + let desired_api_version = FooVersion::from_api_version(desired_api_version) + .map_err(|source| ::stackable_versioned::ConvertObjectError::ParseDesiredApiVersion { + source, + })?; + let mut converted_objects = ::std::vec::Vec::with_capacity(objects.len()); + for object in objects { + let current_object = Self::from_json_object(object.clone()) + .map_err(|source| ::stackable_versioned::ConvertObjectError::Parse { + source, + })?; + match (current_object, desired_api_version) { + (Self::V1Alpha1(__sv_foo), FooVersion::V1Alpha2) => { + let converted: v1alpha2::Foo = __sv_foo.into(); + let desired_object = Self::V1Alpha2(converted); + let desired_object = desired_object + .into_json_value() + .map_err(|source| ::stackable_versioned::ConvertObjectError::Serialize { + source, + })?; + converted_objects.push(desired_object); + } + (Self::V1Alpha2(__sv_foo), FooVersion::V1Alpha1) => { + let converted: v1alpha1::Foo = __sv_foo.into(); + let desired_object = Self::V1Alpha1(converted); + let desired_object = desired_object + .into_json_value() + .map_err(|source| ::stackable_versioned::ConvertObjectError::Serialize { + source, + })?; + converted_objects.push(desired_object); + } + _ => converted_objects.push(object), + } + } + ::std::result::Result::Ok(converted_objects) + } + fn from_json_object( + object_value: ::serde_json::Value, + ) -> ::std::result::Result { + let kind = object_value + .get("kind") + .ok_or_else(|| ::stackable_versioned::ParseObjectError::FieldNotPresent { + field: "kind".to_owned(), + })? + .as_str() + .ok_or_else(|| ::stackable_versioned::ParseObjectError::FieldNotStr { + field: "kind".to_owned(), + })?; + if kind != "Foo" { + return Err(::stackable_versioned::ParseObjectError::UnexpectedKind { + kind: kind.to_owned(), + expected: "Foo".to_owned(), + }); + } + let api_version = object_value + .get("apiVersion") + .ok_or_else(|| ::stackable_versioned::ParseObjectError::FieldNotPresent { + field: "apiVersion".to_owned(), + })? + .as_str() + .ok_or_else(|| ::stackable_versioned::ParseObjectError::FieldNotStr { + field: "apiVersion".to_owned(), + })?; + let object = match api_version { + "stackable.tech/v1alpha1" => { + let object = ::serde_json::from_value(object_value) + .map_err(|source| ::stackable_versioned::ParseObjectError::Deserialize { + source, + })?; + Self::V1Alpha1(object) + } + "stackable.tech/v1alpha2" => { + let object = ::serde_json::from_value(object_value) + .map_err(|source| ::stackable_versioned::ParseObjectError::Deserialize { + source, + })?; + Self::V1Alpha2(object) + } + unknown_api_version => { + return ::std::result::Result::Err(::stackable_versioned::ParseObjectError::UnknownApiVersion { + api_version: unknown_api_version.to_owned(), + }); + } + }; + ::std::result::Result::Ok(object) + } + fn into_json_value( + self, + ) -> ::std::result::Result<::serde_json::Value, ::serde_json::Error> { + match self { + Self::V1Alpha1(__sv_foo) => Ok(::serde_json::to_value(__sv_foo)?), + Self::V1Alpha2(__sv_foo) => Ok(::serde_json::to_value(__sv_foo)?), + } + } +} +#[automatically_derived] +#[derive(::core::marker::Copy, ::core::clone::Clone, ::core::fmt::Debug)] +pub(crate) enum FooVersion { + V1Alpha1, + V1Alpha2, +} +#[automatically_derived] +impl ::core::fmt::Display for FooVersion { + fn fmt( + &self, + f: &mut ::core::fmt::Formatter<'_>, + ) -> ::std::result::Result<(), ::std::fmt::Error> { + f.write_str(self.as_version_str()) + } +} +#[automatically_derived] +impl FooVersion { + pub fn as_version_str(&self) -> &str { + match self { + FooVersion::V1Alpha1 => "v1alpha1", + FooVersion::V1Alpha2 => "v1alpha2", + } + } + pub fn as_api_version_str(&self) -> &str { + match self { + FooVersion::V1Alpha1 => "stackable.tech/v1alpha1", + FooVersion::V1Alpha2 => "stackable.tech/v1alpha2", + } + } + pub fn from_api_version( + api_version: &str, + ) -> Result { + match api_version { + "stackable.tech/v1alpha1" => Ok(FooVersion::V1Alpha1), + "stackable.tech/v1alpha2" => Ok(FooVersion::V1Alpha2), + _ => { + Err(::stackable_versioned::UnknownDesiredApiVersionError { + api_version: api_version.to_owned(), + }) + } + } + } +} +#[cfg(test)] +#[test] +fn FooSpec_roundtrip_down_up() { + ::stackable_versioned::test_utils::test_roundtrip::< + v1alpha2::FooSpec, + >( + stringify!(Foo), + "stackable.tech/v1alpha2", + "stackable.tech/v1alpha1", + Foo::try_convert, + ); +} +#[cfg(test)] +#[test] +fn FooSpec_roundtrip_up_down() { + ::stackable_versioned::test_utils::test_roundtrip::< + v1alpha1::FooSpec, + >( + stringify!(Foo), + "stackable.tech/v1alpha1", + "stackable.tech/v1alpha2", + Foo::try_convert, + ); +} +#[automatically_derived] +#[derive( + ::core::clone::Clone, + ::core::default::Default, + ::core::fmt::Debug, + ::serde::Deserialize, + ::serde::Serialize, + ::schemars::JsonSchema +)] +#[serde(rename_all = "camelCase")] +pub struct FooStatusWithChangedValues { + pub changed_values: ::stackable_versioned::ChangedValues, +} +#[automatically_derived] +impl ::stackable_versioned::TrackingStatus for FooStatusWithChangedValues { + fn changes(&mut self) -> &mut ::stackable_versioned::ChangedValues { + &mut self.changed_values + } +} diff --git a/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@docs.rs.snap b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@docs.rs.snap index 684338443..4655f7ddb 100644 --- a/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@docs.rs.snap +++ b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@docs.rs.snap @@ -89,16 +89,24 @@ where } } #[automatically_derived] -impl ::core::convert::From for v1beta1::Bar { - fn from(__sv_bar: v1alpha1::Bar) -> Self { +impl ::stackable_versioned::TrackingFrom for v1beta1::Bar +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from(__sv_bar: v1alpha1::Bar, status: &mut S, parent: &str) -> Self { + use ::stackable_versioned::TrackingInto as _; match __sv_bar { v1alpha1::Bar::Baz => v1beta1::Bar::Baz, } } } #[automatically_derived] -impl ::core::convert::From for v1alpha1::Bar { - fn from(__sv_bar: v1beta1::Bar) -> Self { +impl ::stackable_versioned::TrackingFrom for v1alpha1::Bar +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from(__sv_bar: v1beta1::Bar, status: &mut S, parent: &str) -> Self { + use ::stackable_versioned::TrackingInto as _; match __sv_bar { v1beta1::Bar::Baz => v1alpha1::Bar::Baz, } @@ -180,16 +188,24 @@ where } } #[automatically_derived] -impl ::core::convert::From for v1beta2::Bar { - fn from(__sv_bar: v1beta1::Bar) -> Self { +impl ::stackable_versioned::TrackingFrom for v1beta2::Bar +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from(__sv_bar: v1beta1::Bar, status: &mut S, parent: &str) -> Self { + use ::stackable_versioned::TrackingInto as _; match __sv_bar { v1beta1::Bar::Baz => v1beta2::Bar::Baz, } } } #[automatically_derived] -impl ::core::convert::From for v1beta1::Bar { - fn from(__sv_bar: v1beta2::Bar) -> Self { +impl ::stackable_versioned::TrackingFrom for v1beta1::Bar +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from(__sv_bar: v1beta2::Bar, status: &mut S, parent: &str) -> Self { + use ::stackable_versioned::TrackingInto as _; match __sv_bar { v1beta2::Bar::Baz => v1beta1::Bar::Baz, } @@ -265,16 +281,24 @@ where } } #[automatically_derived] -impl ::core::convert::From for v1::Bar { - fn from(__sv_bar: v1beta2::Bar) -> Self { +impl ::stackable_versioned::TrackingFrom for v1::Bar +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from(__sv_bar: v1beta2::Bar, status: &mut S, parent: &str) -> Self { + use ::stackable_versioned::TrackingInto as _; match __sv_bar { v1beta2::Bar::Baz => v1::Bar::Baz, } } } #[automatically_derived] -impl ::core::convert::From for v1beta2::Bar { - fn from(__sv_bar: v1::Bar) -> Self { +impl ::stackable_versioned::TrackingFrom for v1beta2::Bar +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from(__sv_bar: v1::Bar, status: &mut S, parent: &str) -> Self { + use ::stackable_versioned::TrackingInto as _; match __sv_bar { v1::Bar::Baz => v1beta2::Bar::Baz, } @@ -350,16 +374,24 @@ where } } #[automatically_derived] -impl ::core::convert::From for v2::Bar { - fn from(__sv_bar: v1::Bar) -> Self { +impl ::stackable_versioned::TrackingFrom for v2::Bar +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from(__sv_bar: v1::Bar, status: &mut S, parent: &str) -> Self { + use ::stackable_versioned::TrackingInto as _; match __sv_bar { v1::Bar::Baz => v2::Bar::Baz, } } } #[automatically_derived] -impl ::core::convert::From for v1::Bar { - fn from(__sv_bar: v2::Bar) -> Self { +impl ::stackable_versioned::TrackingFrom for v1::Bar +where + S: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from(__sv_bar: v2::Bar, status: &mut S, parent: &str) -> Self { + use ::stackable_versioned::TrackingInto as _; match __sv_bar { v2::Bar::Baz => v1::Bar::Baz, } diff --git a/crates/stackable-versioned/CHANGELOG.md b/crates/stackable-versioned/CHANGELOG.md index a2bc02e7a..5db878395 100644 --- a/crates/stackable-versioned/CHANGELOG.md +++ b/crates/stackable-versioned/CHANGELOG.md @@ -9,10 +9,24 @@ All notable changes to this project will be documented in this file. - Add `#[versioned(hint(map))]` to provide a hint for map types during conversion ([#1285]). - Emit generics in plain `From` impl ([#1284]). - Add `from_docs` argument to the `changed()` action to provide the previous doc comments of an item ([#XXXX]). +- Support tracking changes through enums when `experimental_conversion_tracking` is enabled. + Variants containing versioned structs or enums need to be marked with `#[versioned(nested)]` + ([#YYYY]). + +### Changed + +- BREAKING: With `experimental_conversion_tracking` enabled, versioned enums only implement + `TrackingFrom` instead of `From`, in line with structs. Fields using versioned enums need to be + marked with `#[versioned(nested)]` ([#YYYY]). + +### Fixed + +- Fix `From` impls of enum variants with multiple unnamed fields ([#YYYY]). [#1284]: https://github.com/stackabletech/operator-rs/pull/1284 [#1285]: https://github.com/stackabletech/operator-rs/pull/1285 [#XXXX]: https://github.com/stackabletech/operator-rs/pull/XXXX +[#YYYY]: https://github.com/stackabletech/operator-rs/pull/YYYY ## [0.11.1] - 2026-07-06 diff --git a/crates/stackable-versioned/tests/enum_tracking.rs b/crates/stackable-versioned/tests/enum_tracking.rs new file mode 100644 index 000000000..d5846f53a --- /dev/null +++ b/crates/stackable-versioned/tests/enum_tracking.rs @@ -0,0 +1,131 @@ +use kube::{ + CustomResource, + core::{conversion::ConversionReview, response::StatusSummary}, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; + +#[versioned( + version(name = "v1alpha1"), + version(name = "v1alpha2"), + options(k8s(experimental_conversion_tracking)) +)] +pub mod versioned { + #[versioned(crd(group = "test.stackable.tech", doc = "Test"))] + #[derive(Clone, Debug, CustomResource, Deserialize, JsonSchema, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct CatalogSpec { + #[versioned(nested)] + connector: Connector, + } + + #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] + #[serde(rename_all = "camelCase")] + pub enum Connector { + #[versioned(nested)] + Iceberg(IcebergConnector), + + Tpch(TpchConnector), + } + + #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct IcebergConnector { + metastore: Option, + + #[versioned(added(since = "v1alpha2"))] + rest_catalog_uri: Option, + } +} + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct TpchConnector {} + +impl stackable_versioned::test_utils::RoundtripTestData for v1alpha1::CatalogSpec { + fn roundtrip_test_data() -> Vec { + vec![ + Self { + connector: v1alpha1::Connector::Iceberg(v1alpha1::IcebergConnector { + metastore: Some("hive".to_owned()), + }), + }, + Self { + connector: v1alpha1::Connector::Tpch(TpchConnector {}), + }, + ] + } +} + +impl stackable_versioned::test_utils::RoundtripTestData for v1alpha2::CatalogSpec { + fn roundtrip_test_data() -> Vec { + vec![ + // The REST catalog URI doesn't exist in v1alpha1. It is tracked in the status and + // restored when upgrading again. + Self { + connector: v1alpha2::Connector::Iceberg(v1alpha2::IcebergConnector { + metastore: None, + rest_catalog_uri: Some("http://rest-catalog:8181".to_owned()), + }), + }, + Self { + connector: v1alpha2::Connector::Iceberg(v1alpha2::IcebergConnector { + metastore: Some("hive".to_owned()), + rest_catalog_uri: None, + }), + }, + Self { + connector: v1alpha2::Connector::Tpch(TpchConnector {}), + }, + ] + } +} + +#[test] +fn tracks_values_through_enum_variants() { + let review: ConversionReview = serde_json::from_value(serde_json::json!({ + "kind": "ConversionReview", + "apiVersion": "apiextensions.k8s.io/v1", + "request": { + "uid": "c4e55572-ee1f-4e94-9097-28936985d45f", + "desiredAPIVersion": "test.stackable.tech/v1alpha1", + "objects": [{ + "apiVersion": "test.stackable.tech/v1alpha2", + "kind": "Catalog", + "metadata": {}, + "spec": { + "connector": { + "iceberg": { + "metastore": null, + "restCatalogUri": "http://rest-catalog:8181" + } + } + } + }] + } + })) + .expect("conversion review must be valid"); + + let response = Catalog::try_convert(review) + .response + .expect("v1alpha1 review must have a response"); + + assert_eq!(response.result.status, Some(StatusSummary::Success)); + + let object = response + .converted_objects + .first() + .expect("there must be at least one object"); + + assert_eq!( + object["spec"]["connector"]["iceberg"], + serde_json::json!({ "metastore": null }) + ); + assert_eq!( + object["status"]["changedValues"]["upgrades"]["v1alpha2"], + serde_json::json!([{ + "jsonPath": "$.connector.Iceberg.rest_catalog_uri", + "value": "http://rest-catalog:8181" + }]) + ); +}