From e3b590488daa93f64eb6aabb192f2f34911c760c Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 29 Oct 2026 08:36:29 +0100 Subject: [PATCH 1/4] feat(versioned): Add support for the `printcolumn` argument The printcolumn arguments are passed through unchanged to the generated `#[kube(...)]` attribute, so kube-derive does the parsing and validation. This keeps us in sync with upstream without mirroring its PrintColumn struct, and also supports the legacy JSON string syntax. --- .../src/attrs/container.rs | 10 +- .../src/codegen/container/struct/mod.rs | 9 +- crates/stackable-versioned-macros/src/lib.rs | 5 + .../tests/inputs/pass/printcolumn.rs | 33 ++ ...acros__snapshots__pass@printcolumn.rs.snap | 353 ++++++++++++++++++ .../tests/trybuild.rs | 1 + crates/stackable-versioned/CHANGELOG.md | 1 + 7 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 crates/stackable-versioned-macros/tests/inputs/pass/printcolumn.rs create mode 100644 crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@printcolumn.rs.snap diff --git a/crates/stackable-versioned-macros/src/attrs/container.rs b/crates/stackable-versioned-macros/src/attrs/container.rs index 3ebfa2a14..37e422556 100644 --- a/crates/stackable-versioned-macros/src/attrs/container.rs +++ b/crates/stackable-versioned-macros/src/attrs/container.rs @@ -1,5 +1,5 @@ use darling::{Error, FromAttributes, FromMeta, Result, util::Flag}; -use syn::Path; +use syn::{Meta, Path}; #[derive(Debug, FromAttributes)] #[darling(attributes(versioned), and_then = ContainerAttributes::validate)] @@ -51,6 +51,9 @@ pub struct ContainerSkipArguments { /// - `crates`: Override specific crates. /// - `status`: Set the specified struct as the status subresource. /// - `scale`: Configure the scale subresource for horizontal pod autoscaling integration. +/// - `printcolumn`: Add an additional printer column, which is displayed by `kubectl get` and +/// also nicely shows up in tools like k9s. This can be specified multiple times. The argument is +/// passed through as-is, so it supports the same syntax as `#[kube(printcolumn(...))]`. /// - `shortname`: Set a shortname for the CR object. This can be specified multiple /// times. /// - `selectable`: Set the JSON path of a field which can be used as a field selector. @@ -71,7 +74,10 @@ pub struct StructCrdArguments { // derive // schema pub scale: Option, - // printcolumn + // Kept as raw meta and passed through verbatim, so that kube-derive does the parsing and + // validation and we don't need to keep a copy of its printcolumn struct in sync. + #[darling(multiple, rename = "printcolumn")] + pub printcolumns: Vec, #[darling(multiple, rename = "shortname")] pub shortnames: Vec, // category diff --git a/crates/stackable-versioned-macros/src/codegen/container/struct/mod.rs b/crates/stackable-versioned-macros/src/codegen/container/struct/mod.rs index a12d7d02f..e46ea92dd 100644 --- a/crates/stackable-versioned-macros/src/codegen/container/struct/mod.rs +++ b/crates/stackable-versioned-macros/src/codegen/container/struct/mod.rs @@ -312,6 +312,13 @@ impl Struct { )} }); + let printcolumns: TokenStream = spec_gen_ctx + .kubernetes_arguments + .printcolumns + .iter() + .map(|p| quote! { , #p }) + .collect(); + let shortnames: TokenStream = spec_gen_ctx .kubernetes_arguments .shortnames @@ -335,7 +342,7 @@ impl Struct { // These must be comma separated (except the last) as they always exist: group = #group, version = #version, kind = #kind, doc = #doc // These fields are optional, and therefore the token stream must prefix each with a comma: - #singular #plural #namespaced #crates #status #scale #shortnames #selectable_fields + #singular #plural #namespaced #crates #status #scale #printcolumns #shortnames #selectable_fields )] } } diff --git a/crates/stackable-versioned-macros/src/lib.rs b/crates/stackable-versioned-macros/src/lib.rs index 0dec72c5e..51623f66f 100644 --- a/crates/stackable-versioned-macros/src/lib.rs +++ b/crates/stackable-versioned-macros/src/lib.rs @@ -347,6 +347,11 @@ mod utils; /// // tracking is enabled, this struct will be automatically merged into /// // the generated tracking status struct. /// status = "FooStatus", +/// // Add an additional printer column, which is displayed by `kubectl +/// // get` and also nicely shows up in tools like k9s. This can be +/// // specified multiple times and supports the same syntax as +/// // `#[kube(printcolumn(...))]`. The column is used for all versions. +/// printcolumn(name = "Baz", json_path = ".spec.baz", type_ = "boolean"), /// // Set a shortname. This can be specified multiple times. /// shortname = "...", /// // Set the JSON path of a field which can be used as a field diff --git a/crates/stackable-versioned-macros/tests/inputs/pass/printcolumn.rs b/crates/stackable-versioned-macros/tests/inputs/pass/printcolumn.rs new file mode 100644 index 000000000..122610a95 --- /dev/null +++ b/crates/stackable-versioned-macros/tests/inputs/pass/printcolumn.rs @@ -0,0 +1,33 @@ +use stackable_versioned::versioned; +// --- +#[versioned(version(name = "v1alpha1"), version(name = "v1beta1"))] +// --- +pub(crate) mod versioned { + #[versioned(crd( + group = "stackable.tech", + printcolumn(name = "Bar", json_path = ".spec.bar", type_ = "string"), + printcolumn( + name = "Baz", + json_path = ".spec.baz", + type_ = "integer", + description = "The baz value", + format = "int32", + priority = 1 + ), + doc = "Test" + ))] + #[derive( + Clone, + Debug, + serde::Deserialize, + serde::Serialize, + schemars::JsonSchema, + kube::CustomResource, + )] + pub(crate) struct FooSpec { + bar: String, + baz: u16, + } +} +// --- +fn main() {} diff --git a/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@printcolumn.rs.snap b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@printcolumn.rs.snap new file mode 100644 index 000000000..ae093a0d0 --- /dev/null +++ b/crates/stackable-versioned-macros/tests/snapshots/stackable_versioned_macros__snapshots__pass@printcolumn.rs.snap @@ -0,0 +1,353 @@ +--- +source: crates/stackable-versioned-macros/src/lib.rs +expression: formatted +input_file: crates/stackable-versioned-macros/tests/inputs/pass/printcolumn.rs +--- +#[automatically_derived] +pub(crate) mod v1alpha1 { + use super::*; + #[derive( + Clone, + Debug, + serde::Deserialize, + serde::Serialize, + schemars::JsonSchema, + kube::CustomResource, + )] + #[kube( + group = "stackable.tech", + version = "v1alpha1", + kind = "Foo", + doc = "Test", + printcolumn(name = "Bar", json_path = ".spec.bar", type_ = "string"), + printcolumn( + name = "Baz", + json_path = ".spec.baz", + type_ = "integer", + description = "The baz value", + format = "int32", + priority = 1 + ) + )] + pub struct FooSpec { + pub bar: String, + pub baz: u16, + } +} +#[automatically_derived] +impl ::core::convert::From for v1beta1::Foo { + fn from(__sv_foo: v1alpha1::Foo) -> Self { + Self { + metadata: __sv_foo.metadata, + spec: __sv_foo.spec.into(), + } + } +} +#[automatically_derived] +impl ::core::convert::From for v1alpha1::Foo { + fn from(__sv_foo: v1beta1::Foo) -> Self { + Self { + metadata: __sv_foo.metadata, + spec: __sv_foo.spec.into(), + } + } +} +#[automatically_derived] +impl ::core::convert::From for v1beta1::FooSpec { + fn from(__sv_foospec: v1alpha1::FooSpec) -> Self { + Self { + bar: __sv_foospec.bar.into(), + baz: __sv_foospec.baz.into(), + } + } +} +#[automatically_derived] +impl ::core::convert::From for v1alpha1::FooSpec { + fn from(__sv_foospec: v1beta1::FooSpec) -> Self { + Self { + bar: __sv_foospec.bar.into(), + baz: __sv_foospec.baz.into(), + } + } +} +#[automatically_derived] +pub(crate) mod v1beta1 { + use super::*; + #[derive( + Clone, + Debug, + serde::Deserialize, + serde::Serialize, + schemars::JsonSchema, + kube::CustomResource, + )] + #[kube( + group = "stackable.tech", + version = "v1beta1", + kind = "Foo", + doc = "Test", + printcolumn(name = "Bar", json_path = ".spec.bar", type_ = "string"), + printcolumn( + name = "Baz", + json_path = ".spec.baz", + type_ = "integer", + description = "The baz value", + format = "int32", + priority = 1 + ) + )] + pub struct FooSpec { + pub bar: String, + pub baz: u16, + } +} +#[automatically_derived] +#[derive(::core::fmt::Debug)] +pub(crate) enum Foo { + V1Alpha1(v1alpha1::Foo), + V1Beta1(v1beta1::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() + ], + 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::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); + } + _ => 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) + } + 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)?), + } + } +} +#[automatically_derived] +#[derive(::core::marker::Copy, ::core::clone::Clone, ::core::fmt::Debug)] +pub(crate) enum FooVersion { + V1Alpha1, + V1Beta1, +} +#[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", + } + } + pub fn as_api_version_str(&self) -> &str { + match self { + FooVersion::V1Alpha1 => "stackable.tech/v1alpha1", + FooVersion::V1Beta1 => "stackable.tech/v1beta1", + } + } + pub fn from_api_version( + api_version: &str, + ) -> Result { + match api_version { + "stackable.tech/v1alpha1" => Ok(FooVersion::V1Alpha1), + "stackable.tech/v1beta1" => Ok(FooVersion::V1Beta1), + _ => { + Err(::stackable_versioned::UnknownDesiredApiVersionError { + api_version: api_version.to_owned(), + }) + } + } + } +} +#[cfg(test)] +#[test] +fn FooSpec_roundtrip_down_up() { + ::stackable_versioned::test_utils::test_roundtrip::< + v1beta1::FooSpec, + >( + stringify!(Foo), + "stackable.tech/v1beta1", + "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/v1beta1", + Foo::try_convert, + ); +} diff --git a/crates/stackable-versioned-macros/tests/trybuild.rs b/crates/stackable-versioned-macros/tests/trybuild.rs index 90859014e..5140cc08a 100644 --- a/crates/stackable-versioned-macros/tests/trybuild.rs +++ b/crates/stackable-versioned-macros/tests/trybuild.rs @@ -34,6 +34,7 @@ mod inputs { // mod hint_map; // mod module; // mod module_preserve; + // mod printcolumn; // mod renamed_field; // mod renamed_kind; // mod scale; diff --git a/crates/stackable-versioned/CHANGELOG.md b/crates/stackable-versioned/CHANGELOG.md index be02eda22..4a9ea9c03 100644 --- a/crates/stackable-versioned/CHANGELOG.md +++ b/crates/stackable-versioned/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Add public `ExtractIfExt` trait ([#1306]). - Re-add support for generics in tracking impls ([#1304]). - Add support to provide `#[versioned(crd(selectable = "..."))]` arguments to configure selectable fields ([#1312]). +- Add support to provide `#[versioned(crd(printcolumn(...)))]` arguments to configure additional printer columns ([#XXXX]). ### Changed From c98ed1cc32d5a520d6a4a7f48ecf1f18b2d716f7 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 29 Oct 2026 08:39:48 +0100 Subject: [PATCH 2/4] changelog --- crates/stackable-versioned/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/stackable-versioned/CHANGELOG.md b/crates/stackable-versioned/CHANGELOG.md index 4a9ea9c03..4695117aa 100644 --- a/crates/stackable-versioned/CHANGELOG.md +++ b/crates/stackable-versioned/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file. - Add public `ExtractIfExt` trait ([#1306]). - Re-add support for generics in tracking impls ([#1304]). - Add support to provide `#[versioned(crd(selectable = "..."))]` arguments to configure selectable fields ([#1312]). -- Add support to provide `#[versioned(crd(printcolumn(...)))]` arguments to configure additional printer columns ([#XXXX]). +- Add support to provide `#[versioned(crd(printcolumn(...)))]` arguments to configure additional printer columns ([#1318]). ### Changed @@ -36,6 +36,7 @@ All notable changes to this project will be documented in this file. [#1312]: https://github.com/stackabletech/operator-rs/pull/1312 [#1313]: https://github.com/stackabletech/operator-rs/pull/1313 [#1314]: https://github.com/stackabletech/operator-rs/pull/1314 +[#1318]: https://github.com/stackabletech/operator-rs/pull/1318 ## [0.13.0] - 2026-10-14 From 03de8b479688dc85edea7dfcd2fb21a204d0b4ee Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 29 Oct 2026 09:03:14 +0100 Subject: [PATCH 3/4] refactor: Less boilerplate --- .../src/codegen/container/struct/mod.rs | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/crates/stackable-versioned-macros/src/codegen/container/struct/mod.rs b/crates/stackable-versioned-macros/src/codegen/container/struct/mod.rs index e46ea92dd..f10fe243d 100644 --- a/crates/stackable-versioned-macros/src/codegen/container/struct/mod.rs +++ b/crates/stackable-versioned-macros/src/codegen/container/struct/mod.rs @@ -312,27 +312,9 @@ impl Struct { )} }); - let printcolumns: TokenStream = spec_gen_ctx - .kubernetes_arguments - .printcolumns - .iter() - .map(|p| quote! { , #p }) - .collect(); - - let shortnames: TokenStream = spec_gen_ctx - .kubernetes_arguments - .shortnames - .iter() - .map(|s| quote! { , shortname = #s }) - .collect(); - - let selectable_fields: TokenStream = spec_gen_ctx - .kubernetes_arguments - .selectable_fields - .iter() - .map(|s| quote! { , selectable = #s }) - .collect(); - + let printcolumns = &spec_gen_ctx.kubernetes_arguments.printcolumns; + let shortnames = &spec_gen_ctx.kubernetes_arguments.shortnames; + let selectable_fields = &spec_gen_ctx.kubernetes_arguments.selectable_fields; let doc = &spec_gen_ctx.kubernetes_arguments.doc; quote! { @@ -342,7 +324,10 @@ impl Struct { // These must be comma separated (except the last) as they always exist: group = #group, version = #version, kind = #kind, doc = #doc // These fields are optional, and therefore the token stream must prefix each with a comma: - #singular #plural #namespaced #crates #status #scale #printcolumns #shortnames #selectable_fields + #singular #plural #namespaced #crates #status #scale + #(, #printcolumns)* + #(, shortname = #shortnames)* + #(, selectable = #selectable_fields)* )] } } From e186826c70d905be41967f60f5e7484c0e71c67f Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 29 Oct 2026 09:03:26 +0100 Subject: [PATCH 4/4] docs: Warn about field moves --- crates/stackable-versioned-macros/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/stackable-versioned-macros/src/lib.rs b/crates/stackable-versioned-macros/src/lib.rs index 51623f66f..c475c9211 100644 --- a/crates/stackable-versioned-macros/src/lib.rs +++ b/crates/stackable-versioned-macros/src/lib.rs @@ -350,7 +350,11 @@ mod utils; /// // Add an additional printer column, which is displayed by `kubectl /// // get` and also nicely shows up in tools like k9s. This can be /// // specified multiple times and supports the same syntax as -/// // `#[kube(printcolumn(...))]`. The column is used for all versions. +/// // `#[kube(printcolumn(...))]`. The column is used for all versions, +/// // so the JSON path must be valid in every version. Keep in mind +/// // that changing (renaming) or deprecating a field also changes its +/// // name. Kubernetes doesn't validate the path, the column just shows +/// // `` in versions where the field doesn't exist. /// printcolumn(name = "Baz", json_path = ".spec.baz", type_ = "boolean"), /// // Set a shortname. This can be specified multiple times. /// shortname = "...",