#[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
- 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. - For each declared version, a new module containing the containers is
generated. This enables you to reference the container by versions via
v1alpha1::Foo
. - 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 withsuper::
. Additionally, other macros can have trouble using items referred to withsuper::
.
#[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
- 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:
- Only modules named the same like defined versions will be re-emitted. Using modules with invalid names will return an error.
- 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:
- Items cannot be added and deprecated in the same version.
- Items cannot be added and changed in the same version.
- Items cannot be changed and deprecated in the same version.
- Items added in version a, renamed 0…n times in versions b1, …, bn and deprecated in version c must ensure a < b1, …, bn < c.
- 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 theDeprecated
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 thedeprecated
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
- The field
bar
is not yet present in versionv1alpha1
and is therefore not generated. - Now the field
bar
is present and usesDefault::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
- Instead of
Default::default()
, the provided functiondefault_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 thefrom_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 thefrom_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
- In version
v1alpha1
the field is namedprev_bar
and uses au16
. - In the next version,
v1beta1
, the field is now namedbar
and usesusize
instead of au16
. 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
- In version
v1alpha1
the fieldbar
is not yet deprecated and thus uses the name without thedeprecated_
prefix. - In version
v1beta1
the field is deprecated and now includes thedeprecated_
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:
Field | Type (Example) | Description |
---|---|---|
k8s.crd.conversion.converted_object_count | usize (6) | The number of successfully converted objects sent back in a conversion review |
k8s.crd.conversion.desired_api_version | String (v1alpha1) | The desired api version received via a conversion review |
k8s.crd.conversion.api_version | String (v1beta1) | The current api version of an object received via a conversion review |
k8s.crd.conversion.steps | usize (2) | The number of steps required to convert a single object from the current to the desired version |
k8s.crd.conversion.kind | String (Foo) | The kind of the CRD |