Attribute Macro versioned

Source
#[versioned]
Expand description

This macro enables generating versioned structs and enums.

In this guide, code blocks usually come in pairs. The first code block describes how the macro is used. The second expandable block displays the generated piece of code for explanation purposes. It should be noted, that the exact code can diverge from what is being depicted in this guide. Most code is heavily simplified. For example, #[automatically_derived] and #[allow(deprecated)] are removed in most examples to reduce visual clutter.

It is important to note that this macro must be placed before any other (derive) macros and attributes. Macros supplied before the versioned macro will be erased, because the original struct, enum or module (container) is erased, and new containers are generated. This ensures that the macros and attributes are applied to the generated versioned instances of the container.

§Version Declarations

Before any of the fields or variants can be versioned, versions need to be declared at the module level. Each version currently supports two parameters: name and the deprecated flag. The name must be a valid (and supported) format.

Currently, only Kubernetes API versions are supported. The macro checks each declared version and reports any error encountered during parsing.

It should be noted that the defined struct always represents the latest version, eg: when defining three versions v1alpha1, v1beta1, and v1, the struct will describe the structure of the data in v1. This behaviour is especially noticeable in the changed() action which works “backwards” by describing how a field looked before the current (latest) version.

TODO: Version declarations should eventually be moved back to containers.

#[versioned(version(name = "v1alpha1"))]
mod versioned {
    struct Foo {
        bar: usize,
    }
}
Generated code
  1. The #[automatically_derived] attribute indicates that the following piece of code is automatically generated by a macro instead of being handwritten by a developer. This information is used by cargo and rustc.
  2. For each declared version, a new module containing the containers is generated. This enables you to reference the container by versions via v1alpha1::Foo.
  3. This use statement gives the generated containers access to the imports at the top of the file. This is a convenience, because otherwise you would need to prefix used items with super::. Additionally, other macros can have trouble using items referred to with super::.
#[automatically_derived] // 1
mod v1alpha1 {           // 2
    use super::*;        // 3
    pub struct Foo {
        bar: usize,
    }
}

§Version Deprecation

The deprecated flag marks the version as deprecated. This currently adds the #[deprecated] attribute to the appropriate piece of code. In the future, this will additionally mark the CRD version with deprecated: true. See the official docs on version deprecation.

#[versioned(version(name = "v1alpha1", deprecated))]
mod versioned {
    struct Foo {
        bar: usize,
    }
}
Generated code
  1. The deprecated flag will generate a #[deprecated] attribute and the note is automatically generated.
#[automatically_derived]
#[deprecated = "Version v1alpha1 is deprecated"] // 1
mod v1alpha1 {
    use super::*;
    pub struct Foo {
        pub bar: usize,
    }
}

§Version Sorting

Additionally, it is ensured that each version is unique. Declaring the same version multiple times will result in an error. Furthermore, declaring the versions out-of-order is prohibited by default. It is possible to opt-out of this check by setting options(allow_unsorted).

It is not recommended to use this setting and instead use sorted versions across all versioned items.

#[versioned(
    version(name = "v1beta1"),
    version(name = "v1alpha1"),
    options(allow_unsorted)
)]
mod versioned {
    struct Foo {
        bar: usize,
    }
}

§Versioning Module

The purpose of the macro is to version Kubernetes CustomResourceDefinitions (CRDs). As such, the design and how it works is focused on defining and versioning these CRDs. These CRDs are defined as a top-level struct which can them self contain many sub structs.

To be able to maximize the visibility on items comprising the CRD, the macro needs to be applied to module blocks. The name of the module can be freely chosen. Throughout this guide, the name versioned is used. The module is erased in the generated code. This behaviour can however be customized.

TODO: Mention visibility of module

§Preserve Module

The previous examples completely replaced the versioned module with top-level version modules. This is the default behaviour. Preserving the module can however be enabled by setting the preserve_module flag.

#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1"),
    options(preserve_module)
)]
mod versioned {
    struct Foo {
        bar: usize,
    }

    struct Bar {
        baz: String,
    }
}

§Crate Overrides

Override the import path of specific crates which is especially useful if the crates are brought into scope through re-exports. The following code block depicts supported overrides and their default values.

#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1"),

    crates(
        versioned = "::stackable_versioned",
        kube_client = "::kube::client",
        k8s_openapi = "::k8s_openapi",
        serde_json = "::serde_json",
        kube_core = "::kube::core",
        schemars = "::schemars",
        serde = "::serde",
    )
)]
mod versioned {
    // ...
}

§Additional Options

This section contains optional options which influence parts of the code generation.

#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1"),

    options(k8s(
        // Highly experimental conversion tracking. Opting into this feature will
        // introduce frequent breaking changes.
        experimental_conversion_tracking,

        // Enables instrumentation and log events via the tracing crate.
        enable_tracing,
    ))
)]
mod versioned {
    // ...
}

§Merging Submodules

Modules defined in the versioned module will be re-emitted. This allows for composition of re-exports to compose easier to use imports for downstream consumers of versioned containers. The following rules apply:

  1. Only modules named the same like defined versions will be re-emitted. Using modules with invalid names will return an error.
  2. Only use statements defined in the module will be emitted. Declaring other items will return an error.
#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1")
)]
mod versioned {
    mod v1alpha1 {
        pub use a::v1alpha1::*;
        pub use b::v1alpha1::*;
    }

    struct Foo {
        bar: usize,
    }
}
Expand Generated Code
mod v1alpha1 {
    use super::*;
    pub use a::v1alpha1::*;
    pub use b::v1alpha1::*;
    pub struct Foo {
        pub bar: usize,
    }
}

mod v1 {
    use super::*;
    pub struct Foo {
        pub bar: usize,
    }
}

§CRD Spec Definition

§Arguments

It should be noted that not every #[kube] argument is supported or forwarded without changes.

Structs annotated with #[versioned(crd()] are treated as top-level CRD spec definitions. This section lists all currently supported arguments. Most of these arguments are directly forwarded to the underlying #[kube] attribute. Some arguments are specific to this macro and don’t exist in the upstream [kube] crate.

#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1")
)]
mod versioned {
    #[versioned(crd(
        // **Required.** Set the group of the CRD, usually the domain of the
        // company, like `example.com`.
        group = "example.com",

        // Override the kind field of the CRD. This defaults to the struct
        // name (without the `Spec` suffix). Overriding this value will also
        // influence the names of other generated items, like the status
        // struct (if used) or the version enum.
        kind = "CustomKind",

        // Set the singular name. Defaults to lowercased `kind` value.
        singular = "...",

        // Set the plural name. Defaults to inferring from singular.
        plural = "...",

        // Indicate that this is a namespaced scoped resource rather than a
        // cluster scoped resource.
        namespaced,

        // Set the specified struct as the status subresource. If conversion
        // tracking is enabled, this struct will be automatically merged into
        // the generated tracking status struct.
        status = "FooStatus",

        // Set a shortname. This can be specified multiple times.
        shortname = "..."
    ))]
    pub struct FooSpec {
        #[versioned(deprecated(since = "v1beta1"))]
        deprecated_bar: usize,
        baz: bool,
    }
}

§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:

  1. Items cannot be added and deprecated in the same version.
  2. Items cannot be added and changed in the same version.
  3. Items cannot be changed and deprecated in the same version.
  4. Items added in version a, renamed 0…n times in versions b1, …, bn and deprecated in 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.

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.

§Added Action

This action indicates that an item is added in a particular version. Available arguments are:

  • since to indicate since which version the item is present.
  • default to customize the default function used to populate the item in auto-generated conversion implementations.
#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1")
)]
mod versioned {
    pub struct Foo {
        #[versioned(added(since = "v1beta1"))]
        bar: usize,
        baz: bool,
    }
}
Expand Generated Code
  1. The field bar is not yet present in version v1alpha1 and is therefore not generated.
  2. Now the field bar is present and uses Default::default() to populate the field during conversion. This function can be customized as shown later in this guide.
pub mod v1alpha1 {
    use super::*;
    pub struct Foo {                 // 1
        pub baz: bool,
    }
}

impl From<v1alpha1::Foo> for v1beta1::Foo {
    fn from(foo: v1alpha1::Foo) -> Self {
        Self {
            bar: Default::default(), // 2
            baz: foo.baz,
        }
    }
}

pub mod v1beta1 {
    use super::*;
    pub struct Foo {
        pub bar: usize,              // 2
        pub baz: bool,
    }
}
§Custom Default Function

To customize the default function used in the generated conversion implementations the added action provides the default argument. It expects a path to a function without braces. This path can for example point at free-standing or associated functions.

#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1")
)]
mod versioned {
    pub struct Foo {
        #[versioned(added(since = "v1beta1", default = "default_bar"))]
        bar: usize,
        baz: bool,
    }
}

fn default_bar() -> usize {
    42
}
Expand Generated Code
  1. Instead of Default::default(), the provided function default_bar() is used. It is of course fully type checked and needs to return the expected type (usize in this case).
// Snip

impl From<v1alpha1::Foo> for v1beta1::Foo {
    fn from(foo: v1alpha1::Foo) -> Self {
        Self {
            bar: default_bar(), // 1
            baz: foo.baz,
        }
    }
}

// Snip

§Changed Action

This action indicates that an item is changed in a particular version. It combines renames and type changes into a single action. You can choose to change the name, change the type or do both. Available arguments are:

  • since to indicate since which version the item is changed.
  • from_name to indicate from which previous name the field is renamed.
  • from_type to indicate from which previous type the field is changed.
  • upgrade_with to provide a custom upgrade function. This argument can only be used in combination with the from_type argument. The expected function signature is: fn (OLD_TYPE) -> NEW_TYPE. This function must not fail.
  • downgrade_with to provide a custom downgrade function. This argument can only be used in combination with the from_type argument. The expected function signature is: fn (NEW_TYPE) -> OLD_TYPE. This function must not fail.
#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1")
)]
mod versioned {
    pub struct Foo {
        #[versioned(changed(
            since = "v1beta1",
            from_name = "prev_bar",
            from_type = "u16",
            downgrade_with = usize_to_u16
        ))]
        bar: usize,
        baz: bool,
    }
}

fn usize_to_u16(input: usize) -> u16 {
    input.try_into().unwrap()
}
Expand Generated Code
  1. In version v1alpha1 the field is named prev_bar and uses a u16.
  2. In the next version, v1beta1, the field is now named bar and uses usize instead of a u16. The conversion implementations transforms the type automatically.
pub mod v1alpha1 {
    use super::*;
    pub struct Foo {
        pub prev_bar: u16,            // 1
        pub baz: bool,
    }
}

impl From<v1alpha1::Foo> for v1beta1::Foo {
    fn from(foo: v1alpha1::Foo) -> Self {
        Self {
            bar: foo.prev_bar.into(), // 2
            baz: foo.baz,
        }
    }
}

pub mod v1beta1 {
    use super::*;
    pub struct Foo {
        pub bar: usize,               // 2
        pub baz: bool,
    }
}

§Deprecated Action

This action indicates that an item is deprecated in a particular version. Deprecated items are not removed. Available arguments are:

  • since to indicate since which version the item is deprecated.
  • note to specify an optional deprecation note.
#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1")
)]
mod versioned {
    pub struct Foo {
        #[versioned(deprecated(since = "v1beta1"))]
        deprecated_bar: usize,
        baz: bool,
    }
}
Expand Generated Code
  1. In version v1alpha1 the field bar is not yet deprecated and thus uses the name without the deprecated_ prefix.
  2. In version v1beta1 the field is deprecated and now includes the deprecated_ prefix. It also uses the #[deprecated] attribute to indicate to Clippy this part of Rust code is deprecated. Therefore, the conversion implementations include #[allow(deprecated)] to allow the usage of deprecated items in automatically generated code.
pub mod v1alpha1 {
    use super::*;
    pub struct Foo {
        pub bar: usize,                     // 1
        pub baz: bool,
    }
}

#[allow(deprecated)]                        // 2
impl From<v1alpha1::Foo> for v1beta1::Foo {
    fn from(foo: v1alpha1::Foo) -> Self {
        Self {
            deprecated_bar: foo.bar,        // 2
            baz: foo.baz,
        }
    }
}

pub mod v1beta1 {
    use super::*;
    pub struct Foo {
        #[deprecated]                       // 2
        pub deprecated_bar: usize,
        pub baz: bool,
    }
}

§Generated Helpers

This macro generates a few different helpers to enable different operations around CRD versioning and conversion. The following sections explain these helpers and (some) of the code behind them in detail.

All these helpers are generated as associated functions on what this macro calls an entry enum. When defining the following three versions: v1alpha1, v1beta1, and v1 the following entry enum will be generated:

pub enum Foo {
    V1Alpha1(v1alpha1::Foo),
    V1Beta1(v1beta1::Foo),
    V1(v1::Foo),
}

§Merge CRD Versions

The generated merged_crd method is a wrapper around kube’s merge_crds function. It automatically calls the crd methods of the CRD in all of its versions and additionally provides a strongly typed selector for the stored API version.

#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1")
)]
mod versioned {
    #[versioned(crd(group = "example.com"))]
    #[derive(Clone, Debug, Deserialize, Serialize, CustomResource, JsonSchema)]
    pub struct FooSpec {
        #[versioned(added(since = "v1beta1"))]
        bar: usize,
        baz: bool,
    }
}

let merged_crd = Foo::merged_crd(FooVersion::V1Beta1).unwrap();
println!("{yaml}", yaml = serde_yaml::to_string(&merged_crd).unwrap());

The strongly typed version enum looks very similar to the entry enum described above. It additionally provides various associated functions used for parsing and string representations.

#[derive(Copy, Clone, Debug)]
pub enum FooVersion {
    V1Alpha1,
    V1Beta1,
}

The generation of merging helpers can be skipped if manual implementation is desired. The following piece of code lists all possible locations where this skip flag can be provided.

#[versioned(skip(merged_crd))]     // Skip generation for ALL specs
mod versioned {
    #[versioned(skip(merged_crd))] // Skip generation for specific specs
    pub struct FooSpec {}
}

§Convert CustomResources

The conversion of CRs is tightly integrated with [ConversionReview]s, the payload which a conversion webhook receives from the Kubernetes apiserver. Naturally, the try_convert function takes in [ConversionReview] as a parameter and also returns a [ConversionReview] indicating success or failure.

#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1"),
    version(name = "v1")
)]
mod versioned {
    #[versioned(crd(group = "example.com"))]
    #[derive(Clone, Debug, Deserialize, Serialize, CustomResource, JsonSchema)]
    pub struct FooSpec {
        #[versioned(added(since = "v1beta1"))]
        bar: usize,

        #[versioned(added(since = "v1"))]
        baz: bool,

        quox: String,
    }
}

let conversion_review = Foo::try_convert(conversion_review);

The generation of conversion helpers can be skipped if manual implementation is desired. The following piece of code lists all possible locations where this skip flag can be provided:

#[versioned(skip(try_convert))]     // Skip generation for ALL specs
mod versioned {
    #[versioned(skip(try_convert))] // Skip generation for specific specs
    pub struct FooSpec {}
}

§Conversion Tracking

This is a highly experimental feature. To enable it, provide the experimental_conversion_tracking flag. See the additional options section for more information.

As per recommendation by the Kubernetes project, conversions should aim to be infallible and lossless. The above example perfectly illustrates that achieving this is not as easy as it looks on the surface. Let’s assume the following conditions:

  • The CRD’s latest version v1 is marked as the stored version.
  • A client requests a CR in an earlier version, in this case v1alpha1.

The Kubernetes apiserver retrieves the stored object in v1 from etcd. It needs to be downgraded to v1alpha1 to be able to serve the client the correct requested version. As defined above, the field baz was only added in v1 and bar was added in v1beta1 and as such both don’t exist in v1alpha1. During the downgrade, the conversion would lose these pieces of data when upgrading to v1 again after the client did it’s changes. This macro however provides a mechanism to automatically track values across conversations without data loss.

Currently, only tracking of added fields is supported. This will be expanded to removed fields, field type changes, and fields containing collections in the future.

There are many moving parts to enable this mechanism to work automatically with minimal manual developer input. Pretty much all of the required code can be generated based on a simple CRD definition described above. The following paragraphs explain various parts of the system in more detail. For a complete overview, it is however advised to look at the source code of the macro and the code it produces.

§Tracking Values in the Status

Tracking changed values across conversions requires state. In this context, storing this state as close to the CustomResource as possible is essential. This is due to various factors such as avoiding external calls (which are susceptible to network errors), reducing the risk of state drift, and making the use of conversions as easy as possible straight out of the box; for both users and cluster administrators.

As such, values are tracked using the CustomResource’s status via the changedValues field. This field contains two sections, one for upgrades and one for downgrades. Both of these sections contain version keys which list all tracked values for a particular version.

status:
  changedValues:
    downgrades: null
    upgrades:
      v1beta1:
        - fieldName: "bar"
          value: 42
      v1:
        - fieldName: "baz"
          value: true

To continue the above example, upgrading the CustomResource from v1alpha1 back to v1 requires us to re-hydrate the resource with the tracked values. First, the resource is upgraded to v1beta1. During this step, the tracked value for the bar field is applied and removed from the status afterwards. The final upgrade to v1 will apply the tracked value for the field baz. Again, it is removed from the status afterwards.

§Tracking Nested Changes

To be able to automatically track values of changed fields in nested sub structs of specs, the fields needs to be marked with #[versioned(nested)]. This will indicate the macro to generate the appropriate conversion functions.

#[versioned(
    version(name = "v1alpha1"),
    version(name = "v1beta1"),
    options(k8s(experimental_conversion_tracking))
)]
mod versioned {
    #[versioned(crd(group = "example.com"))]
    #[derive(Clone, Debug, Deserialize, Serialize, CustomResource, JsonSchema)]
    struct FooSpec {
        bar: usize,

        // TODO: This technically needs to be combined with a change, but
        // we want proper, per-container versioning before we add the correct
        // attributes here.
        #[versioned(nested)]
        baz: Baz,
    }

    #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
    struct Baz {
        quax: String,

        #[versioned(added(since = "v1beta1"))]
        quox: bool,
    }
}

§OpenTelemetry Semantic Conventions

If tracing is enabled, various traces and events are emitted. The fields of these signals follow the general rules of OpenTelemetry semantic conventions. There are currently no agreed-upon semantic conventions for CRD conversions. In the meantime these fields are used:

FieldType (Example)Description
k8s.crd.conversion.converted_object_countusize (6)The number of successfully converted objects sent back in a conversion review
k8s.crd.conversion.desired_api_versionString (v1alpha1)The desired api version received via a conversion review
k8s.crd.conversion.api_versionString (v1beta1)The current api version of an object received via a conversion review
k8s.crd.conversion.stepsusize (2)The number of steps required to convert a single object from the current to the desired version
k8s.crd.conversion.kindString (Foo)The kind of the CRD