From 9537789d570cbc7aa9511977f5c3f8a5e7bf02f6 Mon Sep 17 00:00:00 2001 From: Techassi Date: Mon, 2 Dec 2024 16:29:45 +0100 Subject: [PATCH] feat: Add typed scale argument to derive macro This allows cutomizing the scale subresource by providing key-value items instead of a raw JSON string. For backwards-compatibility, it is still supported to provide a JSON string. However, all examples and tests were converted to the new format. Signed-off-by: Techassi --- examples/crd_api.rs | 5 +- examples/crd_derive.rs | 5 +- kube-derive/Cargo.toml | 1 + kube-derive/src/custom_resource.rs | 123 +++++++++++++++++++++++++++-- kube/src/lib.rs | 5 +- 5 files changed, 129 insertions(+), 10 deletions(-) diff --git a/examples/crd_api.rs b/examples/crd_api.rs index dfbd52e7e..d0123b28b 100644 --- a/examples/crd_api.rs +++ b/examples/crd_api.rs @@ -19,7 +19,10 @@ use kube::{ #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, Validate, JsonSchema)] #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] #[kube(status = "FooStatus")] -#[kube(scale = r#"{"specReplicasPath":".spec.replicas", "statusReplicasPath":".status.replicas"}"#)] +#[kube(scale( + spec_replicas_path = ".spec.replicas", + status_replicas_path = ".status.replicas" +))] #[kube(printcolumn = r#"{"name":"Team", "jsonPath": ".spec.metadata.team", "type": "string"}"#)] pub struct FooSpec { #[schemars(length(min = 3))] diff --git a/examples/crd_derive.rs b/examples/crd_derive.rs index 4aace0193..0ec836aeb 100644 --- a/examples/crd_derive.rs +++ b/examples/crd_derive.rs @@ -22,7 +22,10 @@ use serde::{Deserialize, Serialize}; derive = "PartialEq", derive = "Default", shortname = "f", - scale = r#"{"specReplicasPath":".spec.replicas", "statusReplicasPath":".status.replicas"}"#, + scale( + spec_replicas_path = ".spec.replicas", + status_replicas_path = ".status.replicas" + ), printcolumn = r#"{"name":"Spec", "type":"string", "description":"name of foo", "jsonPath":".spec.name"}"#, selectable = "spec.name" )] diff --git a/kube-derive/Cargo.toml b/kube-derive/Cargo.toml index 0b89ea2f0..a758602b1 100644 --- a/kube-derive/Cargo.toml +++ b/kube-derive/Cargo.toml @@ -19,6 +19,7 @@ proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["extra-traits"] } serde_json.workspace = true +k8s-openapi = { workspace = true, features = ["latest"] } darling.workspace = true [lib] diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index 055664f31..b0c8f3f13 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -2,6 +2,7 @@ #![allow(clippy::manual_unwrap_or_default)] use darling::{FromDeriveInput, FromMeta}; +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceSubresourceScale; use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::{ToTokens, TokenStreamExt}; use syn::{parse_quote, Data, DeriveInput, Path, Visibility}; @@ -34,7 +35,12 @@ struct KubeAttrs { printcolums: Vec, #[darling(multiple)] selectable: Vec, - scale: Option, + + /// Customize the scale subresource, see [Kubernetes docs][1]. + /// + /// [1]: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource + scale: Option, + #[darling(default)] crates: Crates, #[darling(multiple, rename = "annotation")] @@ -185,6 +191,107 @@ impl FromMeta for SchemaMode { } } +/// A new-type wrapper around [`CustomResourceSubresourceScale`] to support parsing from the +/// `#[kube]` attribute. +#[derive(Debug)] +struct Scale(CustomResourceSubresourceScale); + +// This custom FromMeta implementation is needed for two reasons: +// +// - To enable backwards-compatibility. Up to version 0.97.0 it was only possible to set scale +// subresource values as a JSON string. +// - k8s_openapi types don't support being parsed directly from attributes using darling. This +// would require an upstream change, which is highly unlikely to occur. The from_list impl uses +// the derived implementation as inspiration. +impl FromMeta for Scale { + /// This is implemented for backwards-compatibility. It allows that the scale subresource can + /// be deserialized from a JSON string. + fn from_string(value: &str) -> darling::Result { + let scale = serde_json::from_str(value).map_err(|err| darling::Error::custom(err))?; + Ok(Self(scale)) + } + + fn from_list(items: &[darling::ast::NestedMeta]) -> darling::Result { + let mut errors = darling::Error::accumulator(); + + let mut label_selector_path: (bool, Option>) = (false, None); + let mut spec_replicas_path: (bool, Option) = (false, None); + let mut status_replicas_path: (bool, Option) = (false, None); + + for item in items { + match item { + darling::ast::NestedMeta::Meta(meta) => { + let name = darling::util::path_to_string(meta.path()); + + match name.as_str() { + "label_selector_path" => { + let path = errors.handle(darling::FromMeta::from_meta(meta)); + label_selector_path = (true, Some(path)) + } + "spec_replicas_path" => { + let path = errors.handle(darling::FromMeta::from_meta(meta)); + spec_replicas_path = (true, path) + } + "status_replicas_path" => { + let path = errors.handle(darling::FromMeta::from_meta(meta)); + status_replicas_path = (true, path) + } + other => return Err(darling::Error::unknown_field(other)), + } + } + darling::ast::NestedMeta::Lit(lit) => { + errors.push(darling::Error::unsupported_format("literal").with_span(&lit.span())) + } + } + } + + if !label_selector_path.0 { + match as darling::FromMeta>::from_none() { + Some(fallback) => label_selector_path.1 = Some(fallback), + None => errors.push(darling::Error::missing_field("spec_replicas_path")), + } + } + + if !spec_replicas_path.0 && spec_replicas_path.1.is_none() { + errors.push(darling::Error::missing_field("spec_replicas_path")); + } + + if !status_replicas_path.0 && status_replicas_path.1.is_none() { + errors.push(darling::Error::missing_field("status_replicas_path")); + } + + errors.finish_with(Self(CustomResourceSubresourceScale { + label_selector_path: label_selector_path.1.unwrap(), + spec_replicas_path: spec_replicas_path.1.unwrap(), + status_replicas_path: status_replicas_path.1.unwrap(), + })) + } +} + +impl Scale { + fn to_tokens(&self, k8s_openapi: &Path) -> TokenStream { + let apiext = quote! { + #k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1 + }; + + let label_selector_path = self + .0 + .label_selector_path + .as_ref() + .map_or_else(|| quote! { None }, |p| quote! { #p.into() }); + let spec_replicas_path = &self.0.spec_replicas_path; + let status_replicas_path = &self.0.status_replicas_path; + + quote! { + #apiext::CustomResourceSubresourceScale { + label_selector_path: #label_selector_path, + spec_replicas_path: #spec_replicas_path.into(), + status_replicas_path: #status_replicas_path.into() + } + } + } +} + pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream { let derive_input: DeriveInput = match syn::parse2(input) { Err(err) => return err.to_compile_error(), @@ -439,7 +546,13 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea .map(|s| format!(r#"{{ "jsonPath": "{s}" }}"#)) .collect(); let fields = format!("[ {} ]", fields.join(",")); - let scale_code = if let Some(s) = scale { s } else { "".to_string() }; + let scale = scale.map_or_else( + || quote! { None }, + |s| { + let scale = s.to_tokens(&k8s_openapi); + quote! { Some(#scale) } + }, + ); // Ensure it generates for the correct CRD version (only v1 supported now) let apiext = quote! { @@ -551,11 +664,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea #k8s_openapi::k8s_if_ge_1_30! { let fields : Vec<#apiext::SelectableField> = #serde_json::from_str(#fields).expect("valid selectableField column json"); } - let scale: Option<#apiext::CustomResourceSubresourceScale> = if #scale_code.is_empty() { - None - } else { - #serde_json::from_str(#scale_code).expect("valid scale subresource json") - }; + let scale: Option<#apiext::CustomResourceSubresourceScale> = #scale; let categories: Vec = #serde_json::from_str(#categories_json).expect("valid categories"); let shorts : Vec = #serde_json::from_str(#short_json).expect("valid shortnames"); let subres = if #has_status { diff --git a/kube/src/lib.rs b/kube/src/lib.rs index e7be35690..0684c4fb9 100644 --- a/kube/src/lib.rs +++ b/kube/src/lib.rs @@ -223,7 +223,10 @@ mod test { #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] #[kube(status = "FooStatus")] - #[kube(scale = r#"{"specReplicasPath":".spec.replicas", "statusReplicasPath":".status.replicas"}"#)] + #[kube(scale( + spec_replicas_path = ".spec.replicas", + status_replicas_path = ".status.replicas" + ))] #[kube(crates(kube_core = "crate::core"))] // for dev-dep test structure pub struct FooSpec { name: String,