Skip to content

Commit

Permalink
feat: Add typed scale argument to derive macro
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
Techassi committed Dec 2, 2024
1 parent 9f93e2f commit 9537789
Show file tree
Hide file tree
Showing 5 changed files with 129 additions and 10 deletions.
5 changes: 4 additions & 1 deletion examples/crd_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
5 changes: 4 additions & 1 deletion examples/crd_derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)]
Expand Down
1 change: 1 addition & 0 deletions kube-derive/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
123 changes: 116 additions & 7 deletions kube-derive/src/custom_resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -34,7 +35,12 @@ struct KubeAttrs {
printcolums: Vec<String>,
#[darling(multiple)]
selectable: Vec<String>,
scale: Option<String>,

/// 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<Scale>,

#[darling(default)]
crates: Crates,
#[darling(multiple, rename = "annotation")]
Expand Down Expand Up @@ -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<Self> {
let scale = serde_json::from_str(value).map_err(|err| darling::Error::custom(err))?;

Check warning on line 210 in kube-derive/src/custom_resource.rs

View workflow job for this annotation

GitHub Actions / clippy_nightly

redundant closure

warning: redundant closure --> kube-derive/src/custom_resource.rs:210:57 | 210 | let scale = serde_json::from_str(value).map_err(|err| darling::Error::custom(err))?; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: replace the closure with the function itself: `darling::Error::custom` | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure = note: `#[warn(clippy::redundant_closure)]` on by default

Check warning on line 210 in kube-derive/src/custom_resource.rs

View workflow job for this annotation

GitHub Actions / clippy_nightly

redundant closure

warning: redundant closure --> kube-derive/src/custom_resource.rs:210:57 | 210 | let scale = serde_json::from_str(value).map_err(|err| darling::Error::custom(err))?; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: replace the closure with the function itself: `darling::Error::custom` | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure = note: `#[warn(clippy::redundant_closure)]` on by default
Ok(Self(scale))
}

fn from_list(items: &[darling::ast::NestedMeta]) -> darling::Result<Self> {
let mut errors = darling::Error::accumulator();

let mut label_selector_path: (bool, Option<Option<String>>) = (false, None);
let mut spec_replicas_path: (bool, Option<String>) = (false, None);
let mut status_replicas_path: (bool, Option<String>) = (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 <Option<String> 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(),
Expand Down Expand Up @@ -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! {
Expand Down Expand Up @@ -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<String> = #serde_json::from_str(#categories_json).expect("valid categories");
let shorts : Vec<String> = #serde_json::from_str(#short_json).expect("valid shortnames");
let subres = if #has_status {
Expand Down
5 changes: 4 additions & 1 deletion kube/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 9537789

Please sign in to comment.