diff --git a/crates/stackable-versioned-macros/src/attrs/item/mod.rs b/crates/stackable-versioned-macros/src/attrs/item/mod.rs index 568f318d7..d4784da80 100644 --- a/crates/stackable-versioned-macros/src/attrs/item/mod.rs +++ b/crates/stackable-versioned-macros/src/attrs/item/mod.rs @@ -29,6 +29,8 @@ pub use variant::*; /// [`Vec`]. /// - An item can only be deprecated once. A field or variant not marked as /// 'deprecated' will be included up until the latest version. +/// - An item can only be removed once. It is not included in the version it is +/// removed in and all later versions. Removal must be the last action. #[derive(Debug, FromMeta)] pub struct CommonItemAttributes { /// This parses the `added` attribute on items (fields or variants). It can @@ -43,6 +45,10 @@ pub struct CommonItemAttributes { /// This parses the `deprecated` attribute on items (fields or variants). It /// can only be present at most once. pub deprecated: Option, + + /// This parses the `removed` attribute on items (fields or variants). It + /// can only be present at most once. + pub removed: Option, } // This impl block ONLY contains validation. The main entrypoint is the associated 'validate' @@ -90,6 +96,25 @@ impl CommonItemAttributes { ).with_span(&deprecated.version.span())); } + if let Some(removed) = &self.removed { + if !versions.iter().any(|v| v.inner == *removed.version) { + errors.push(Error::custom( + "the `removed` action uses a version which is not declared via `#[versioned(version)]`", + ).with_span(&removed.version.span())); + } + + // An item removed in the first version would never exist. + if versions + .first() + .is_some_and(|v| v.inner == *removed.version) + { + errors.push( + Error::custom("cannot be marked as `removed` in the first version") + .with_span(&removed.version.span()), + ); + } + } + errors.finish() } @@ -106,7 +131,29 @@ impl CommonItemAttributes { /// at least one version before being changed. /// - `changed` and `deprecated` using the same version: Again, the same /// rules from above apply here as well. + /// - `removed` and any other action using the same version: Again, the + /// same rules from above apply here as well. fn validate_action_combinations(&self, item_idents: &impl ItemIdents) -> Result<()> { + if let Some(removed) = &self.removed { + let removed_version = *removed.version; + + if self + .added + .as_ref() + .is_some_and(|a| *a.version == removed_version) + || self.changes.iter().any(|c| *c.version == removed_version) + || self + .deprecated + .as_ref() + .is_some_and(|d| *d.version == removed_version) + { + return Err(Error::custom( + "cannot be marked as `removed` and any other action in the same version", + ) + .with_span(item_idents.original())); + } + } + match (&self.added, &self.changes, &self.deprecated) { (Some(added), _, Some(deprecated)) if *added.version == *deprecated.version => Err( Error::custom("cannot be marked as `added` and `deprecated` in the same version") @@ -142,6 +189,7 @@ impl CommonItemAttributes { /// version of the added action. /// - All `changed` actions must use a greater version than `added` but a /// lesser version than `deprecated`. + /// - `removed` must use a greater version than all other actions. fn validate_action_order(&self, item_idents: &impl ItemIdents) -> Result<()> { let added_version = self.added.as_ref().map(|a| *a.version); let deprecated_version = self.deprecated.as_ref().map(|d| *d.version); @@ -169,6 +217,21 @@ impl CommonItemAttributes { .with_span(item_idents.original())); } + // Lastly, ensure that the item is removed after all other actions. + if let Some(removed) = &self.removed { + let removed_version = removed.version; + + if added_version.is_some_and(|a| a > *removed_version) + || deprecated_version.is_some_and(|d| d > *removed_version) + || self.changes.iter().any(|c| *c.version > *removed_version) + { + return Err(Error::custom( + "`removed` must use a version higher than all other actions", + ) + .with_span(&removed_version.span())); + } + } + Ok(()) } @@ -317,8 +380,32 @@ impl CommonItemAttributes { .collect() } - #[expect(clippy::too_many_lines)] pub fn into_changeset( + mut self, + idents: &impl ItemIdents, + ty: Type, + ) -> Option> { + let removed = self.removed.take(); + let mut actions = self.into_changeset_without_removal(idents, ty); + + // The removal is always the last action (which is ensured during validation). The item uses + // the original ident in the last version it is present in, regardless if it was deprecated + // or not. + if let Some(removed) = removed { + actions.get_or_insert_default().insert( + *removed.version, + ItemStatus::Removal { + default_fn: removed.default_fn.deref().clone(), + ident: idents.original().clone(), + }, + ); + } + + actions + } + + #[expect(clippy::too_many_lines)] + fn into_changeset_without_removal( self, idents: &impl ItemIdents, ty: Type, @@ -513,3 +600,17 @@ pub struct DeprecatedAttributes { pub note: Option>, pub previous_docs: Option>, } + +/// For the removed() action +/// +/// Example usage: +/// - `removed(in = "...")` +/// - `removed(in = "...", default = "custom_fn")` +#[derive(Clone, Debug, FromMeta)] +pub struct RemovedAttributes { + #[darling(rename = "in")] + pub version: SpannedValue, + + #[darling(rename = "default", default = "default_default_fn")] + pub default_fn: SpannedValue, +} diff --git a/crates/stackable-versioned-macros/src/attrs/item/variant.rs b/crates/stackable-versioned-macros/src/attrs/item/variant.rs index 4fe85072b..41f02ecab 100644 --- a/crates/stackable-versioned-macros/src/attrs/item/variant.rs +++ b/crates/stackable-versioned-macros/src/attrs/item/variant.rs @@ -60,6 +60,15 @@ impl VariantAttributes { errors.handle(self.common.validate(&variant_idents, &self.attrs)); + // Removing a variant would require a variant to map to during upgrades, which is currently + // not supported. + if let Some(removed) = &self.common.removed { + errors.push( + Error::custom("the `removed` action is currently not supported on enum variants") + .with_span(&removed.version.span()), + ); + } + // Validate names of renames for change in &self.common.changes { if let Some(previous_name) = &change.previous_name diff --git a/crates/stackable-versioned-macros/src/codegen/changes.rs b/crates/stackable-versioned-macros/src/codegen/changes.rs index 3e1355a4f..cd3e53512 100644 --- a/crates/stackable-versioned-macros/src/codegen/changes.rs +++ b/crates/stackable-versioned-macros/src/codegen/changes.rs @@ -132,6 +132,16 @@ impl ChangesetExt for BTreeMap { ty: Box::new(ty.clone()), }, ), + // If the removal is the only action, the item is present with its original + // ident and type in all versions before. + ItemStatus::Removal { ident, .. } => self.insert( + version.inner, + ItemStatus::NoChange { + previously_deprecated: false, + ident: ident.clone(), + ty: Box::new(ty.clone()), + }, + ), ItemStatus::NoChange { previously_deprecated, ident, @@ -163,7 +173,11 @@ impl ChangesetExt for BTreeMap { ty, .. } => (ident, ty, *previously_deprecated), - ItemStatus::NotPresent => unreachable!(), + // The item is not present in any version after it has been removed. + ItemStatus::Removal { .. } | ItemStatus::NotPresent => { + self.insert(version.inner, ItemStatus::NotPresent); + continue; + } }; self.insert( @@ -189,7 +203,9 @@ impl ChangesetExt for BTreeMap { ty, .. } => (ident, ty, *previously_deprecated), - ItemStatus::NotPresent => { + // NOTE: The removal is always the last action, so it can't have an upper + // neighbor. It is still handled here for completeness. + ItemStatus::Removal { .. } | ItemStatus::NotPresent => { self.insert(version.inner, ItemStatus::NotPresent); continue; } diff --git a/crates/stackable-versioned-macros/src/codegen/container/struct/conversion.rs b/crates/stackable-versioned-macros/src/codegen/container/struct/conversion.rs index 02bf77a0f..4ff9cdc79 100644 --- a/crates/stackable-versioned-macros/src/codegen/container/struct/conversion.rs +++ b/crates/stackable-versioned-macros/src/codegen/container/struct/conversion.rs @@ -389,6 +389,7 @@ impl Struct { ItemStatus::Change { .. } => s.is_type_change(), ItemStatus::Deprecation { .. } | ItemStatus::NoChange { .. } + | ItemStatus::Removal { .. } | ItemStatus::NotPresent => false, } }) diff --git a/crates/stackable-versioned-macros/src/codegen/item/field.rs b/crates/stackable-versioned-macros/src/codegen/item/field.rs index 08102e13b..da0e11cc0 100644 --- a/crates/stackable-versioned-macros/src/codegen/item/field.rs +++ b/crates/stackable-versioned-macros/src/codegen/item/field.rs @@ -156,7 +156,7 @@ impl VersionedField { pub #field_ident: #field_type, }) } - ItemStatus::NotPresent => None, + ItemStatus::Removal { .. } | ItemStatus::NotPresent => None, ItemStatus::NoChange { previously_deprecated, ident, @@ -225,8 +225,11 @@ impl VersionedField { match (change, next_change) { // If both this status and the next one is NotPresent, which means a field was // introduced after a bunch of versions, we don't need to generate any code for - // the From impl. - (ItemStatus::NotPresent, ItemStatus::NotPresent) => None, + // the From impl. The same applies to all versions after a field was removed. + ( + ItemStatus::NotPresent | ItemStatus::Removal { .. }, + ItemStatus::NotPresent, + ) => None, ( _, ItemStatus::Addition { @@ -236,6 +239,19 @@ impl VersionedField { Direction::Upgrade => Some(quote! { #ident: #default_fn(), }), Direction::Downgrade => None, }, + // The field doesn't exist in the next version anymore. As such, it is dropped + // during upgrades and needs to be initialized during downgrades. + // TODO: Track the value of removed fields when experimental_conversion_tracking + // is enabled. Currently, the value is lost during the upgrade. + ( + _, + ItemStatus::Removal { + ident, default_fn, .. + }, + ) => match direction { + Direction::Upgrade => None, + Direction::Downgrade => Some(quote! { #ident: #default_fn(), }), + }, ( _, ItemStatus::Change { @@ -307,8 +323,9 @@ impl VersionedField { let changes = self.changes.as_ref()?; match direction { - // This arm is only relevant for removed fields which are currently - // not supported. + // This arm is only relevant for removed fields, which are currently + // not tracked. + // TODO: Track the value of removed fields. Direction::Upgrade => None, // When we generate code for a downgrade, any changes which need to diff --git a/crates/stackable-versioned-macros/src/codegen/item/mod.rs b/crates/stackable-versioned-macros/src/codegen/item/mod.rs index 8b5ecde8d..64f25131f 100644 --- a/crates/stackable-versioned-macros/src/codegen/item/mod.rs +++ b/crates/stackable-versioned-macros/src/codegen/item/mod.rs @@ -66,6 +66,11 @@ pub enum ItemStatus { ident: IdentString, ty: Box, }, + Removal { + /// The ident of the item in the last version it is present in. + ident: IdentString, + default_fn: Path, + }, NotPresent, } @@ -78,7 +83,8 @@ impl ItemStatus { .. } | Self::Deprecation { ident, .. } - | Self::NoChange { ident, .. } => ident, + | Self::NoChange { ident, .. } + | Self::Removal { ident, .. } => ident, Self::NotPresent => unreachable!("ItemStatus::NotPresent does not have an ident"), } } diff --git a/crates/stackable-versioned-macros/src/codegen/item/variant.rs b/crates/stackable-versioned-macros/src/codegen/item/variant.rs index 22bf43da0..d2348b347 100644 --- a/crates/stackable-versioned-macros/src/codegen/item/variant.rs +++ b/crates/stackable-versioned-macros/src/codegen/item/variant.rs @@ -139,7 +139,8 @@ impl VersionedVariant { #ident #fields, }) } - ItemStatus::NotPresent => None, + // Removing variants is rejected during validation. + ItemStatus::Removal { .. } | ItemStatus::NotPresent => None, }, None => { // If there is no chain of variant actions, the variant is not diff --git a/crates/stackable-versioned-macros/src/lib.rs b/crates/stackable-versioned-macros/src/lib.rs index 0df530807..358f63b03 100644 --- a/crates/stackable-versioned-macros/src/lib.rs +++ b/crates/stackable-versioned-macros/src/lib.rs @@ -377,9 +377,9 @@ mod utils; /// /// ## Field Actions /// -/// This crate currently supports three different item actions. Items can -/// be added, changed, and deprecated. The macro ensures that these actions -/// adhere to the following set of rules: +/// This crate currently supports four different item actions. Items can +/// be added, changed, deprecated, and removed. The macro ensures that these +/// actions adhere to the following set of rules: /// /// 1. Items cannot be added and deprecated in the same version. /// 2. Items cannot be added and changed in the same version. @@ -389,13 +389,17 @@ mod utils; /// version _c_ must ensure _a < b1, ..., bn < c_. /// 5. All item actions must use previously declared versions. Using versions /// not present at the container level will result in an error. +/// 6. Items cannot be removed in the same version as any other action. +/// 7. Items removed in version _d_ must ensure that _d_ is greater than the +/// versions of all other actions. Items cannot be removed in the first +/// version. /// /// For items marked as deprecated, one additional rule applies: /// /// - Fields must start with the `deprecated_` and variants with the -/// `Deprecated` prefix. This is enforced because Kubernetes doesn't allow -/// removing fields in CRDs entirely. Instead, they should be marked as -/// deprecated. By convention this is done with the `deprecated` prefix. +/// `Deprecated` prefix. Deprecated items continue to exist in all later +/// versions. By convention this is indicated with the `deprecated` prefix. +/// To drop a field from later versions, use the `removed` action. /// /// ### Added Action /// @@ -579,7 +583,8 @@ mod utils; /// ### Deprecated Action /// /// This action indicates that an item is deprecated in a particular version. -/// Deprecated items are not removed. Available arguments are: +/// Deprecated items are not removed, see the [removed action](#removed-action) +/// for that. Available arguments are: /// /// - `in` to indicate in which version the item is deprecated. /// - `note` to specify an optional deprecation note. @@ -638,6 +643,107 @@ mod utils; /// ``` /// /// +/// ### Removed Action +/// +/// This action indicates that an item is removed in a particular version. The +/// item is not part of the container in that version and all later versions. +/// The item can, but doesn't need to be deprecated in an earlier version. +/// Removing enum variants is currently not supported. Available arguments are: +/// +/// - `in` to indicate in which version the item is removed. +/// - `default` to customize the function used to initialize the item when +/// downgrading to the last version the item is present in. Uses +/// [`Default::default`] if not provided. +/// +///
+/// +/// The value of a removed item is lost when upgrading to the version it is +/// removed in. Removed items are currently not tracked by the +/// [conversion tracking](#conversion-tracking) mechanism. +/// +///
+/// +/// ``` +/// # use stackable_versioned_macros::versioned; +/// #[versioned( +/// version(name = "v1alpha1"), +/// version(name = "v1beta1"), +/// version(name = "v1") +/// )] +/// mod versioned { +/// pub struct Foo { +/// #[versioned( +/// deprecated(in = "v1beta1"), +/// removed(in = "v1", default = "default_bar") +/// )] +/// deprecated_bar: usize, +/// baz: bool, +/// } +/// } +/// +/// fn default_bar() -> usize { +/// 42 +/// } +/// ``` +/// +///
+/// Expand Generated Code +/// +/// 1. In version `v1alpha1` the field `bar` is neither deprecated nor removed. +/// 2. In version `v1beta1` the field is deprecated and includes the +/// `deprecated_` prefix. +/// 3. In version `v1` the field is removed. Its value is dropped during the +/// upgrade. +/// 4. When downgrading from `v1`, the field is initialized using the provided +/// `default_bar()` function. +/// +/// ```ignore +/// pub mod v1alpha1 { +/// use super::*; +/// pub struct Foo { +/// pub bar: usize, // 1 +/// pub baz: bool, +/// } +/// } +/// +/// // Snip +/// +/// pub mod v1beta1 { +/// use super::*; +/// pub struct Foo { +/// #[deprecated] // 2 +/// pub deprecated_bar: usize, +/// pub baz: bool, +/// } +/// } +/// +/// pub mod v1 { +/// use super::*; +/// pub struct Foo { +/// pub baz: bool, // 3 +/// } +/// } +/// +/// impl From for v1::Foo { +/// fn from(foo: v1beta1::Foo) -> Self { +/// Self { +/// baz: foo.baz, // 3 +/// } +/// } +/// } +/// +/// #[allow(deprecated)] +/// impl From for v1beta1::Foo { +/// fn from(foo: v1::Foo) -> Self { +/// Self { +/// deprecated_bar: default_bar(), // 4 +/// baz: foo.baz, +/// } +/// } +/// } +/// ``` +///
+/// /// ### Changed Docs /// /// The doc comments of an item can be changed using the `previous_docs` argument diff --git a/crates/stackable-versioned-macros/tests/inputs/fail/removed.rs b/crates/stackable-versioned-macros/tests/inputs/fail/removed.rs new file mode 100644 index 000000000..de04d9b04 --- /dev/null +++ b/crates/stackable-versioned-macros/tests/inputs/fail/removed.rs @@ -0,0 +1,35 @@ +use stackable_versioned_macros::versioned; + +#[versioned( + version(name = "v1alpha1"), + version(name = "v1beta1"), + version(name = "v1") +)] +mod versioned { + struct RemovedInFirstVersion { + #[versioned(removed(in = "v1alpha1"))] + foo: usize, + } + + struct RemovedInUnknownVersion { + #[versioned(removed(in = "v2"))] + foo: usize, + } + + struct RemovedAndDeprecatedInSameVersion { + #[versioned(deprecated(in = "v1"), removed(in = "v1"))] + deprecated_foo: usize, + } + + struct RemovedBeforeChange { + #[versioned(changed(in = "v1", previous_name = "bar"), removed(in = "v1beta1"))] + foo: usize, + } + + enum RemovedVariant { + #[versioned(removed(in = "v1"))] + Foo, + } +} + +fn main() {} diff --git a/crates/stackable-versioned-macros/tests/inputs/fail/removed.stderr b/crates/stackable-versioned-macros/tests/inputs/fail/removed.stderr new file mode 100644 index 000000000..90f6ec5d1 --- /dev/null +++ b/crates/stackable-versioned-macros/tests/inputs/fail/removed.stderr @@ -0,0 +1,29 @@ +error: cannot be marked as `removed` in the first version + --> tests/inputs/fail/removed.rs:10:34 + | +10 | #[versioned(removed(in = "v1alpha1"))] + | ^^^^^^^^^^ + +error: the `removed` action uses a version which is not declared via `#[versioned(version)]` + --> tests/inputs/fail/removed.rs:15:34 + | +15 | #[versioned(removed(in = "v2"))] + | ^^^^ + +error: cannot be marked as `removed` and any other action in the same version + --> tests/inputs/fail/removed.rs:21:9 + | +21 | deprecated_foo: usize, + | ^^^^^^^^^^^^^^ + +error: `removed` must use a version higher than all other actions + --> tests/inputs/fail/removed.rs:25:77 + | +25 | #[versioned(changed(in = "v1", previous_name = "bar"), removed(in = "v1beta1"))] + | ^^^^^^^^^ + +error: the `removed` action is currently not supported on enum variants + --> tests/inputs/fail/removed.rs:30:34 + | +30 | #[versioned(removed(in = "v1"))] + | ^^^^ diff --git a/crates/stackable-versioned-macros/tests/inputs/pass/conversion_tracking_removed.rs b/crates/stackable-versioned-macros/tests/inputs/pass/conversion_tracking_removed.rs new file mode 100644 index 000000000..44b179feb --- /dev/null +++ b/crates/stackable-versioned-macros/tests/inputs/pass/conversion_tracking_removed.rs @@ -0,0 +1,38 @@ +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use stackable_versioned::versioned; +// --- +#[versioned( + version(name = "v1alpha1"), + version(name = "v1beta1"), + version(name = "v1"), + 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 { + // Removed fields are currently not tracked. Their value is lost during upgrades. + #[versioned(removed(in = "v1beta1"))] + bar: usize, + + #[versioned(deprecated(in = "v1beta1"), removed(in = "v1"))] + deprecated_baz: bool, + + #[versioned(nested)] + quux: Quux, + } + + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub(crate) struct Quux { + corge: String, + + // An added field is tracked until it is removed. + #[versioned(added(in = "v1beta1"), removed(in = "v1"))] + grault: String, + } +} +// --- +fn main() {} diff --git a/crates/stackable-versioned-macros/tests/inputs/pass/removed.rs b/crates/stackable-versioned-macros/tests/inputs/pass/removed.rs new file mode 100644 index 000000000..2c40304ca --- /dev/null +++ b/crates/stackable-versioned-macros/tests/inputs/pass/removed.rs @@ -0,0 +1,49 @@ +use stackable_versioned_macros::versioned; +// --- +#[versioned( + version(name = "v1alpha1"), + version(name = "v1alpha2"), + version(name = "v1beta1"), + version(name = "v1"), + version(name = "v2") +)] +// --- +mod versioned { + struct Foo { + username: String, + + /// Removed without being deprecated first. + #[versioned(removed(in = "v1alpha2"))] + nickname: String, + + #[versioned( + deprecated(in = "v1alpha2"), + removed(in = "v1beta1", default = default_age) + )] + deprecated_age: u32, + + #[versioned( + changed( + in = "v1alpha2", + previous_name = "colour", + previous_type = "u8", + downgrade_with = u16_to_u8 + ), + removed(in = "v1") + )] + color: u16, + + #[versioned(added(in = "v1alpha2"), removed(in = "v1"))] + email: String, + } +} +// --- +fn main() {} + +fn default_age() -> u32 { + 42 +} + +fn u16_to_u8(input: u16) -> u8 { + input.try_into().unwrap() +} diff --git a/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@conversion_tracking_removed.rs.snap b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@conversion_tracking_removed.rs.snap new file mode 100644 index 000000000..56ab42611 --- /dev/null +++ b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@conversion_tracking_removed.rs.snap @@ -0,0 +1,626 @@ +--- +source: crates/stackable-versioned-macros/src/lib.rs +expression: formatted +input_file: crates/stackable-versioned-macros/tests/inputs/pass/conversion_tracking_removed.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 bar: usize, + pub baz: bool, + pub quux: Quux, + } + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub struct Quux { + pub corge: String, + } +} +#[automatically_derived] +impl ::core::convert::From for v1beta1::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: v1beta1::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] +#[allow(deprecated)] +impl<__SvStatus> ::stackable_versioned::TrackingFrom +for v1beta1::FooSpec +where + __SvStatus: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_foospec: v1alpha1::FooSpec, + status: &mut __SvStatus, + parent: &str, + ) -> Self { + use ::stackable_versioned::{ExtractIfExt as _, TrackingInto as _}; + let __sv_quux_path = ::stackable_versioned::jthong_path(parent, "quux"); + let mut spec = Self { + deprecated_baz: __sv_foospec.baz.into(), + quux: __sv_foospec.quux.tracking_into(status, &__sv_quux_path), + }; + spec + } +} +#[automatically_derived] +#[allow(deprecated)] +impl<__SvStatus> ::stackable_versioned::TrackingFrom +for v1alpha1::FooSpec +where + __SvStatus: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_foospec: v1beta1::FooSpec, + status: &mut __SvStatus, + parent: &str, + ) -> Self { + use ::stackable_versioned::{ExtractIfExt as _, TrackingInto as _}; + let __sv_quux_path = ::stackable_versioned::jthong_path(parent, "quux"); + let mut spec = Self { + bar: ::std::default::Default::default(), + baz: __sv_foospec.deprecated_baz.into(), + quux: __sv_foospec.quux.tracking_into(status, &__sv_quux_path), + }; + spec + } +} +#[automatically_derived] +impl<__SvStatus> ::stackable_versioned::TrackingFrom +for v1beta1::Quux +where + __SvStatus: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_quux: v1alpha1::Quux, + status: &mut __SvStatus, + parent: &str, + ) -> Self { + use ::stackable_versioned::{ExtractIfExt as _, TrackingInto as _}; + let __sv_grault_path = ::stackable_versioned::jthong_path(parent, "grault"); + let mut spec = Self { + corge: __sv_quux.corge.into(), + grault: ::std::default::Default::default(), + }; + if let Some(upgrades) = status.changes().upgrades.get_mut("v1beta1") { + if let Some(::stackable_versioned::ChangedValue { value, .. }) = upgrades + .extract_first_if(|u| u.json_path == __sv_grault_path) + { + spec.grault = ::serde_json::from_value(value) + .expect( + "internal error: {__sv_grault_path} must be serializable for spec.grault", + ); + } + if upgrades.is_empty() { + status.changes().upgrades.remove("v1beta1"); + } + } + spec + } +} +#[automatically_derived] +impl<__SvStatus> ::stackable_versioned::TrackingFrom +for v1alpha1::Quux +where + __SvStatus: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_quux: v1beta1::Quux, + status: &mut __SvStatus, + parent: &str, + ) -> Self { + use ::stackable_versioned::{ExtractIfExt as _, TrackingInto as _}; + let __sv_grault_path = ::stackable_versioned::jthong_path(parent, "grault"); + let upgrades = status + .changes() + .upgrades + .entry("v1beta1".to_owned()) + .or_default(); + upgrades + .push(::stackable_versioned::ChangedValue { + json_path: __sv_grault_path, + value: ::serde_json::to_value(&__sv_quux.grault).unwrap(), + downgraded_value: ::core::option::Option::None, + }); + let mut spec = Self { + corge: __sv_quux.corge.into(), + }; + spec + } +} +#[automatically_derived] +pub(crate) mod v1beta1 { + use super::*; + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, CustomResource)] + #[kube( + group = "stackable.tech", + version = "v1beta1", + kind = "Foo", + doc = "Test", + status = FooStatusWithChangedValues + )] + pub struct FooSpec { + #[deprecated] + pub deprecated_baz: bool, + pub quux: Quux, + } + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub struct Quux { + pub corge: String, + pub grault: String, + } +} +#[automatically_derived] +impl ::core::convert::From for v1::Foo { + fn from(__sv_foo: v1beta1::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 v1beta1::Foo { + fn from(__sv_foo: v1::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] +#[allow(deprecated)] +impl<__SvStatus> ::stackable_versioned::TrackingFrom +for v1::FooSpec +where + __SvStatus: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_foospec: v1beta1::FooSpec, + status: &mut __SvStatus, + parent: &str, + ) -> Self { + use ::stackable_versioned::{ExtractIfExt as _, TrackingInto as _}; + let __sv_quux_path = ::stackable_versioned::jthong_path(parent, "quux"); + let mut spec = Self { + quux: __sv_foospec.quux.tracking_into(status, &__sv_quux_path), + }; + spec + } +} +#[automatically_derived] +#[allow(deprecated)] +impl<__SvStatus> ::stackable_versioned::TrackingFrom +for v1beta1::FooSpec +where + __SvStatus: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_foospec: v1::FooSpec, + status: &mut __SvStatus, + parent: &str, + ) -> Self { + use ::stackable_versioned::{ExtractIfExt as _, TrackingInto as _}; + let __sv_quux_path = ::stackable_versioned::jthong_path(parent, "quux"); + let mut spec = Self { + deprecated_baz: ::std::default::Default::default(), + quux: __sv_foospec.quux.tracking_into(status, &__sv_quux_path), + }; + spec + } +} +#[automatically_derived] +impl<__SvStatus> ::stackable_versioned::TrackingFrom +for v1::Quux +where + __SvStatus: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_quux: v1beta1::Quux, + status: &mut __SvStatus, + parent: &str, + ) -> Self { + use ::stackable_versioned::{ExtractIfExt as _, TrackingInto as _}; + let mut spec = Self { + corge: __sv_quux.corge.into(), + }; + spec + } +} +#[automatically_derived] +impl<__SvStatus> ::stackable_versioned::TrackingFrom +for v1beta1::Quux +where + __SvStatus: ::stackable_versioned::TrackingStatus + ::core::default::Default, +{ + fn tracking_from( + __sv_quux: v1::Quux, + status: &mut __SvStatus, + parent: &str, + ) -> Self { + use ::stackable_versioned::{ExtractIfExt as _, TrackingInto as _}; + let mut spec = Self { + corge: __sv_quux.corge.into(), + grault: ::std::default::Default::default(), + }; + spec + } +} +#[automatically_derived] +pub(crate) mod v1 { + use super::*; + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, CustomResource)] + #[kube( + group = "stackable.tech", + version = "v1", + kind = "Foo", + doc = "Test", + status = FooStatusWithChangedValues + )] + pub struct FooSpec { + pub quux: Quux, + } + #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] + pub struct Quux { + pub corge: String, + } +} +#[automatically_derived] +#[derive(::core::fmt::Debug)] +pub(crate) enum Foo { + V1Alpha1(v1alpha1::Foo), + V1Beta1(v1beta1::Foo), + V1(v1::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(), < + v1beta1::Foo as ::kube::core::CustomResourceExt > ::crd(), < v1::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::V1Beta1) => { + let converted: v1beta1::Foo = __sv_foo.into(); + let desired_object = Self::V1Beta1(converted); + let desired_object = desired_object + .into_json_value() + .map_err(|source| ::stackable_versioned::ConvertObjectError::Serialize { + source, + })?; + converted_objects.push(desired_object); + } + (Self::V1Alpha1(__sv_foo), FooVersion::V1) => { + let converted: v1beta1::Foo = __sv_foo.into(); + let converted: v1::Foo = converted.into(); + let desired_object = Self::V1(converted); + let desired_object = desired_object + .into_json_value() + .map_err(|source| ::stackable_versioned::ConvertObjectError::Serialize { + source, + })?; + converted_objects.push(desired_object); + } + (Self::V1Beta1(__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); + } + (Self::V1Beta1(__sv_foo), FooVersion::V1) => { + let converted: v1::Foo = __sv_foo.into(); + let desired_object = Self::V1(converted); + let desired_object = desired_object + .into_json_value() + .map_err(|source| ::stackable_versioned::ConvertObjectError::Serialize { + source, + })?; + converted_objects.push(desired_object); + } + (Self::V1(__sv_foo), FooVersion::V1Alpha1) => { + let converted: v1beta1::Foo = __sv_foo.into(); + let converted: v1alpha1::Foo = converted.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); + } + (Self::V1(__sv_foo), FooVersion::V1Beta1) => { + let converted: v1beta1::Foo = __sv_foo.into(); + let desired_object = Self::V1Beta1(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/v1beta1" => { + let object = ::serde_json::from_value(object_value) + .map_err(|source| ::stackable_versioned::ParseObjectError::Deserialize { + source, + })?; + Self::V1Beta1(object) + } + "stackable.tech/v1" => { + let object = ::serde_json::from_value(object_value) + .map_err(|source| ::stackable_versioned::ParseObjectError::Deserialize { + source, + })?; + Self::V1(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::V1Beta1(__sv_foo) => Ok(::serde_json::to_value(__sv_foo)?), + Self::V1(__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, + V1Beta1, + V1, +} +#[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::V1Beta1 => "v1beta1", + FooVersion::V1 => "v1", + } + } + pub fn as_api_version_str(&self) -> &str { + match self { + FooVersion::V1Alpha1 => "stackable.tech/v1alpha1", + FooVersion::V1Beta1 => "stackable.tech/v1beta1", + FooVersion::V1 => "stackable.tech/v1", + } + } + pub fn from_api_version( + api_version: &str, + ) -> Result { + match api_version { + "stackable.tech/v1alpha1" => Ok(FooVersion::V1Alpha1), + "stackable.tech/v1beta1" => Ok(FooVersion::V1Beta1), + "stackable.tech/v1" => Ok(FooVersion::V1), + _ => { + Err(::stackable_versioned::UnknownDesiredApiVersionError { + api_version: api_version.to_owned(), + }) + } + } + } +} +#[cfg(test)] +#[test] +fn FooSpec_roundtrip_down_up() { + ::stackable_versioned::test_utils::test_roundtrip::< + v1::FooSpec, + >(stringify!(Foo), "stackable.tech/v1", "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/v1", 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@removed.rs.snap b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@removed.rs.snap new file mode 100644 index 000000000..095fc3c3f --- /dev/null +++ b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@removed.rs.snap @@ -0,0 +1,131 @@ +--- +source: crates/stackable-versioned-macros/src/lib.rs +expression: formatted +input_file: crates/stackable-versioned-macros/tests/inputs/pass/removed.rs +--- +#[automatically_derived] +mod v1alpha1 { + use super::*; + pub struct Foo { + pub username: String, + /// Removed without being deprecated first. + pub nickname: String, + pub age: u32, + pub colour: u8, + } +} +#[automatically_derived] +#[allow(deprecated)] +impl ::core::convert::From for v1alpha2::Foo { + fn from(__sv_foo: v1alpha1::Foo) -> Self { + Self { + username: __sv_foo.username.into(), + deprecated_age: __sv_foo.age.into(), + color: __sv_foo.colour.into(), + email: ::std::default::Default::default(), + } + } +} +#[automatically_derived] +#[allow(deprecated)] +impl ::core::convert::From for v1alpha1::Foo { + fn from(__sv_foo: v1alpha2::Foo) -> Self { + Self { + username: __sv_foo.username.into(), + nickname: ::std::default::Default::default(), + age: __sv_foo.deprecated_age.into(), + colour: u16_to_u8(__sv_foo.color), + } + } +} +#[automatically_derived] +mod v1alpha2 { + use super::*; + pub struct Foo { + pub username: String, + #[deprecated] + pub deprecated_age: u32, + pub color: u16, + pub email: String, + } +} +#[automatically_derived] +#[allow(deprecated)] +impl ::core::convert::From for v1beta1::Foo { + fn from(__sv_foo: v1alpha2::Foo) -> Self { + Self { + username: __sv_foo.username.into(), + color: __sv_foo.color.into(), + email: __sv_foo.email.into(), + } + } +} +#[automatically_derived] +#[allow(deprecated)] +impl ::core::convert::From for v1alpha2::Foo { + fn from(__sv_foo: v1beta1::Foo) -> Self { + Self { + username: __sv_foo.username.into(), + deprecated_age: default_age(), + color: __sv_foo.color.into(), + email: __sv_foo.email.into(), + } + } +} +#[automatically_derived] +mod v1beta1 { + use super::*; + pub struct Foo { + pub username: String, + pub color: u16, + pub email: String, + } +} +#[automatically_derived] +impl ::core::convert::From for v1::Foo { + fn from(__sv_foo: v1beta1::Foo) -> Self { + Self { + username: __sv_foo.username.into(), + } + } +} +#[automatically_derived] +impl ::core::convert::From for v1beta1::Foo { + fn from(__sv_foo: v1::Foo) -> Self { + Self { + username: __sv_foo.username.into(), + color: ::std::default::Default::default(), + email: ::std::default::Default::default(), + } + } +} +#[automatically_derived] +mod v1 { + use super::*; + pub struct Foo { + pub username: String, + } +} +#[automatically_derived] +impl ::core::convert::From for v2::Foo { + fn from(__sv_foo: v1::Foo) -> Self { + Self { + username: __sv_foo.username.into(), + } + } +} +#[automatically_derived] +impl ::core::convert::From for v1::Foo { + fn from(__sv_foo: v2::Foo) -> Self { + Self { + username: __sv_foo.username.into(), + } + } +} +#[automatically_derived] +mod v2 { + use super::*; + pub struct Foo { + pub username: String, + } +} diff --git a/crates/stackable-versioned-macros/tests/trybuild.rs b/crates/stackable-versioned-macros/tests/trybuild.rs index 5140cc08a..9621685bb 100644 --- a/crates/stackable-versioned-macros/tests/trybuild.rs +++ b/crates/stackable-versioned-macros/tests/trybuild.rs @@ -24,6 +24,7 @@ mod inputs { // mod conversion_tracking_enum; // mod conversion_tracking_generics; // mod conversion_tracking; + // mod conversion_tracking_removed; // mod crate_overrides; // mod crate_overrides_only_kube; // mod crd_doc; @@ -35,6 +36,7 @@ mod inputs { // mod module; // mod module_preserve; // mod printcolumn; + // mod removed; // mod renamed_field; // mod renamed_kind; // mod scale; @@ -47,6 +49,7 @@ mod inputs { // mod applied_to_struct; // mod changed; // mod deprecate; + // mod removed; // mod spec_suffix; // mod unknown_version; // mod submodule_invalid_name; diff --git a/crates/stackable-versioned/CHANGELOG.md b/crates/stackable-versioned/CHANGELOG.md index 1e5b74beb..5b57df47f 100644 --- a/crates/stackable-versioned/CHANGELOG.md +++ b/crates/stackable-versioned/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Add support for the `removed` field action, which removes a field in a particular version. + Removed fields are not tracked when `experimental_conversion_tracking` is enabled yet. + It doesn't support enum variants either for now ([#1324]). + +[#1324]: https://github.com/stackabletech/operator-rs/pull/1324 + ## [0.14.0] - 2026-11-03 ### Added