From f45ac81bf33988c5a6bee288c80736d4b4fca084 Mon Sep 17 00:00:00 2001 From: Danil Grigorev Date: Mon, 9 Sep 2024 09:24:08 +0200 Subject: [PATCH] Add `Resource` derive macro (#1565) * Add ResourceInherit derive macro Allows to generate Resource trait implementation for types which proxy another type internally. ConfigMaps and Secrets can be strictly typed on the client side. While using DeserializeGuard, resources can be listed and watched, skipping invalid resources. Signed-off-by: Danil-Grigorev * Rename ResourceInherit to Resource - Display usage in errorbounded CM watcher Signed-off-by: Danil-Grigorev * Reverse order resource/inherit Signed-off-by: Danil-Grigorev * Add cert_check example Signed-off-by: Danil-Grigorev * Example fmt fixes Signed-off-by: Danil-Grigorev * Review: comment and cargo fix Signed-off-by: Danil-Grigorev * Examples readme fix Signed-off-by: Danil-Grigorev --------- Signed-off-by: Danil-Grigorev Co-authored-by: Eirik A --- examples/Cargo.toml | 4 + examples/README.md | 4 + examples/cert_check.rs | 112 ++++++++++++++++++ examples/errorbounded_configmap_watcher.rs | 39 +------ kube-derive/src/lib.rs | 47 ++++++++ kube-derive/src/resource.rs | 127 +++++++++++++++++++++ kube-derive/tests/resource.rs | 55 +++++++++ kube/src/lib.rs | 4 + 8 files changed, 357 insertions(+), 35 deletions(-) create mode 100644 examples/cert_check.rs create mode 100644 kube-derive/src/resource.rs create mode 100644 kube-derive/tests/resource.rs diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 46b656b0d..7b7b14362 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -224,6 +224,10 @@ required-features = ["openssl-tls", "rustls-tls"] name = "custom_client_trace" path = "custom_client_trace.rs" +[[example]] +name = "cert_check" +path = "cert_check.rs" + [[example]] name = "secret_syncer" path = "secret_syncer.rs" diff --git a/examples/README.md b/examples/README.md index 19eb7df7b..e672bd203 100644 --- a/examples/README.md +++ b/examples/README.md @@ -69,6 +69,8 @@ cargo run --example crd_api cargo run --example crd_derive cargo run --example crd_derive_schema cargo run --example crd_derive_no_schema --no-default-features --features=openssl-tls,latest +# collect kube-root configmaps from each namespace, with strictly typed serialization on ca.crt key +cargo run --example cert_check ``` The last one opts out from the default `schema` feature from `kube-derive` (and thus the need for you to derive/impl `JsonSchema`). @@ -93,6 +95,8 @@ cargo run --example multi_watcher cargo run --example node_watcher # watch arbitrary, untyped objects across all namespaces cargo run --example dynamic_watcher +# watch arbitrary, typed config map objects, with error toleration +cargo run --example errorbounded_configmap_watcher ``` The `node_` and `pod_` watcher also allows using [Kubernetes 1.27 Streaming lists](https://kubernetes.io/docs/reference/using-api/api-concepts/#streaming-lists) via `WATCHLIST=1`: diff --git a/examples/cert_check.rs b/examples/cert_check.rs new file mode 100644 index 000000000..0df692293 --- /dev/null +++ b/examples/cert_check.rs @@ -0,0 +1,112 @@ +use std::borrow::Cow; + +use k8s_openapi::{ + api::core::v1::{ConfigMap, Namespace as Ns}, + NamespaceResourceScope, +}; +use kube::{ + api::ObjectMeta, + client::scope::{Cluster, Namespace}, + Client, Resource, ResourceExt, +}; +use serde::{Deserialize, Serialize}; +use tracing::*; + +use thiserror::Error; + +#[derive(Debug, Error)] +enum Error { + #[error("Failed to open client: {0}")] + ClientSetup(#[source] kube::Error), + #[error("Failed to list namespaces: {0}")] + NamespaceList(#[source] kube::Error), + #[error("Failed to get ConfigMap: {0}")] + FetchFailed(#[from] kube::Error), + #[error("Expected certificate key in ConfigMap: {0}")] + MissingKey(#[from] serde_json::Error), +} + +// Variant of ConfigMap that only accepts ConfigMaps with a CA certificate +// to demonstrate manual implementation +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CaConfigMapManual { + metadata: ObjectMeta, + data: CaConfigMapData, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct CaConfigMapData { + #[serde(rename = "ca.crt")] + ca_crt: String, +} + +// Variant of ConfigMap that only accepts ConfigMaps with a CA certificate +// with inherited resource implementation +#[derive(Resource, Serialize, Deserialize, Debug, Clone)] +#[resource(inherit = ConfigMap)] +struct CaConfigMap { + metadata: ObjectMeta, + data: CaConfigMapData, +} + +// Display of a manual implementation +impl Resource for CaConfigMapManual { + type DynamicType = (); + type Scope = NamespaceResourceScope; + + fn kind(&(): &Self::DynamicType) -> Cow<'_, str> { + Cow::Borrowed("ConfigMap") + } + + fn group(&(): &Self::DynamicType) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn version(&(): &Self::DynamicType) -> Cow<'_, str> { + Cow::Borrowed("v1") + } + + fn plural(&(): &Self::DynamicType) -> Cow<'_, str> { + Cow::Borrowed("configmaps") + } + + fn meta(&self) -> &ObjectMeta { + &self.metadata + } + + fn meta_mut(&mut self) -> &mut ObjectMeta { + &mut self.metadata + } +} + + +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing_subscriber::fmt::init(); + + let client = Client::try_default().await.map_err(Error::ClientSetup)?; + let namespaces = client + .list::(&Default::default(), &Cluster) + .await + .map_err(Error::NamespaceList)?; + + for ns in namespaces { + // Equivalent ways to GET using different structs and different Resource impls, with added field validation on top. + let _ca: ConfigMap = client + .get("kube-root-ca.crt", &Namespace::from(ns.name_any())) + .await?; + let _ca: CaConfigMapManual = client + .get("kube-root-ca.crt", &Namespace::from(ns.name_any())) + .await?; + let ca: CaConfigMap = client + .get("kube-root-ca.crt", &Namespace::from(ns.name_any())) + .await?; + info!( + "Found correct root ca config map in {}: {}", + ns.name_any(), + ca.name_any() + ); + } + + Ok(()) +} diff --git a/examples/errorbounded_configmap_watcher.rs b/examples/errorbounded_configmap_watcher.rs index a327087ee..db9e69830 100644 --- a/examples/errorbounded_configmap_watcher.rs +++ b/examples/errorbounded_configmap_watcher.rs @@ -1,9 +1,7 @@ -use std::borrow::Cow; - use futures::prelude::*; -use k8s_openapi::{api::core::v1::Pod, NamespaceResourceScope}; +use k8s_openapi::api::core::v1::ConfigMap; use kube::{ - api::{Api, ObjectMeta, ResourceExt}, + api::{Api, ObjectMeta}, core::DeserializeGuard, runtime::{reflector::ObjectRef, watcher, WatchStreamExt}, Client, Resource, @@ -13,7 +11,8 @@ use tracing::*; // Variant of ConfigMap that only accepts ConfigMaps with a CA certificate // to demonstrate parsing failure -#[derive(Deserialize, Debug, Clone)] +#[derive(Resource, Deserialize, Debug, Clone)] +#[resource(inherit = ConfigMap)] struct CaConfigMap { metadata: ObjectMeta, data: CaConfigMapData, @@ -25,36 +24,6 @@ struct CaConfigMapData { ca_crt: String, } -// Normally you would derive this, but ConfigMap doesn't follow the standard spec/status pattern -impl Resource for CaConfigMap { - type DynamicType = (); - type Scope = NamespaceResourceScope; - - fn kind(&(): &Self::DynamicType) -> Cow<'_, str> { - Cow::Borrowed("ConfigMap") - } - - fn group(&(): &Self::DynamicType) -> Cow<'_, str> { - Cow::Borrowed("") - } - - fn version(&(): &Self::DynamicType) -> Cow<'_, str> { - Cow::Borrowed("v1") - } - - fn plural(&(): &Self::DynamicType) -> Cow<'_, str> { - Cow::Borrowed("configmaps") - } - - fn meta(&self) -> &ObjectMeta { - &self.metadata - } - - fn meta_mut(&mut self) -> &mut ObjectMeta { - &mut self.metadata - } -} - #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index a6ea66ab3..0c59066cc 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -4,6 +4,7 @@ extern crate proc_macro; #[macro_use] extern crate quote; mod custom_resource; +mod resource; /// A custom derive for kubernetes custom resource definitions. /// @@ -308,3 +309,49 @@ mod custom_resource; pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::TokenStream { custom_resource::derive(proc_macro2::TokenStream::from(input)).into() } + +/// A custom derive for inheriting Resource impl for the type. +/// +/// This will generate a [`kube::Resource`] trait implementation, which inherits the specified +/// resources trait implementation. +/// +/// Such implementation allows to add strict typing to some typical resources like `Secret` or `ConfigMap`, +/// in cases when implementing CRD is not desirable or it does not fit the use-case. +/// +/// This object can be used with [`kube::Api`]. +/// +/// # Example +/// +/// ```rust,no_run +/// use kube::api::ObjectMeta; +/// use k8s_openapi::api::core::v1::ConfigMap; +/// use kube_derive::Resource; +/// use kube::Client; +/// use kube::Api; +/// use serde::Deserialize; +/// +/// #[derive(Resource, Clone, Debug, Deserialize)] +/// #[resource(inherit = "ConfigMap")] +/// struct FooMap { +/// metadata: ObjectMeta, +/// data: Option, +/// } +/// +/// #[derive(Clone, Debug, Deserialize)] +/// struct FooMapSpec { +/// field: String, +/// } +/// +/// let client: Client = todo!(); +/// let api: Api = Api::default_namespaced(client); +/// let config_map = api.get("with-field"); +/// ``` +/// +/// The example above will generate: +/// ``` +/// // impl kube::Resource for FooMap { .. } +/// ``` +#[proc_macro_derive(Resource, attributes(resource))] +pub fn derive_resource(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + resource::derive(proc_macro2::TokenStream::from(input)).into() +} diff --git a/kube-derive/src/resource.rs b/kube-derive/src/resource.rs new file mode 100644 index 000000000..69b1929a6 --- /dev/null +++ b/kube-derive/src/resource.rs @@ -0,0 +1,127 @@ +// Generated by darling macros, out of our control +#![allow(clippy::manual_unwrap_or_default)] + +use darling::{FromDeriveInput, FromMeta}; +use syn::{parse_quote, Data, DeriveInput, Path}; + +/// Values we can parse from #[kube(attrs)] +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(resource))] +struct InheritAttrs { + inherit: syn::Path, + #[darling(default)] + crates: Crates, +} + +#[derive(Debug, FromMeta)] +struct Crates { + #[darling(default = "Self::default_kube_core")] + kube_core: Path, + #[darling(default = "Self::default_k8s_openapi")] + k8s_openapi: Path, +} + +// Default is required when the subattribute isn't mentioned at all +// Delegate to darling rather than deriving, so that we can piggyback off the `#[darling(default)]` clauses +impl Default for Crates { + fn default() -> Self { + Self::from_list(&[]).unwrap() + } +} + +impl Crates { + fn default_kube_core() -> Path { + parse_quote! { ::kube::core } // by default must work well with people using facade crate + } + + fn default_k8s_openapi() -> Path { + parse_quote! { ::k8s_openapi } + } +} + +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(), + Ok(di) => di, + }; + // Limit derive to structs + match derive_input.data { + Data::Struct(_) | Data::Enum(_) => {} + _ => { + return syn::Error::new_spanned(&derive_input.ident, r#"Unions can not #[derive(Resource)]"#) + .to_compile_error() + } + } + let kube_attrs = match InheritAttrs::from_derive_input(&derive_input) { + Err(err) => return err.write_errors(), + Ok(attrs) => attrs, + }; + + let InheritAttrs { + inherit: resource, + crates: Crates { + kube_core, + k8s_openapi, + }, + .. + } = kube_attrs; + + let rootident = derive_input.ident; + + let inherit_resource = quote! { + impl #kube_core::Resource for #rootident { + type DynamicType = <#resource as #kube_core::Resource>::DynamicType; + type Scope = <#resource as #kube_core::Resource>::Scope; + + fn group(_: &<#resource as #kube_core::Resource>::DynamicType) -> std::borrow::Cow<'_, str> { + #resource::group(&Default::default()).into_owned().into() + } + + fn kind(_: &<#resource as #kube_core::Resource>::DynamicType) -> std::borrow::Cow<'_, str> { + #resource::kind(&Default::default()).into_owned().into() + } + + fn version(_: &<#resource as #kube_core::Resource>::DynamicType) -> std::borrow::Cow<'_, str> { + #resource::version(&Default::default()).into_owned().into() + } + + fn api_version(_: &<#resource as #kube_core::Resource>::DynamicType) -> std::borrow::Cow<'_, str> { + #resource::api_version(&Default::default()).into_owned().into() + } + + fn plural(_: &<#resource as #kube_core::Resource>::DynamicType) -> std::borrow::Cow<'_, str> { + #resource::plural(&Default::default()).into_owned().into() + } + + fn meta(&self) -> &#k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { + &self.metadata + } + + fn meta_mut(&mut self) -> &mut #k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { + &mut self.metadata + } + } + }; + + // Concat output + quote! { + #inherit_resource + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_inherit() { + let input = quote! { + #[derive(Resource)] + #[resource(inherit = "ConfigMap")] + struct Foo { metadata: ObjectMeta } + }; + + let input = syn::parse2(input).unwrap(); + InheritAttrs::from_derive_input(&input).unwrap(); + } +} diff --git a/kube-derive/tests/resource.rs b/kube-derive/tests/resource.rs new file mode 100644 index 000000000..e784c38cb --- /dev/null +++ b/kube-derive/tests/resource.rs @@ -0,0 +1,55 @@ +use k8s_openapi::{ + api::core::v1::{ConfigMap, Secret}, + ByteString, +}; +use kube::api::ObjectMeta; +use kube_derive::Resource; + +#[derive(Resource, Default)] +#[resource(inherit = "ConfigMap")] +struct TypedMap { + metadata: ObjectMeta, + data: Option, +} + +#[derive(Default)] +struct TypedData { + field: String, +} + +#[derive(Resource, Default)] +#[resource(inherit = "Secret")] +struct TypedSecret { + metadata: ObjectMeta, + data: Option, +} + +#[derive(Default)] +struct TypedSecretData { + field: ByteString, +} + +#[cfg(test)] +mod tests { + use kube::Resource; + + use crate::{TypedMap, TypedSecret}; + + #[test] + fn test_parse_config_map_default() { + TypedMap::default(); + assert_eq!(TypedMap::kind(&()), "ConfigMap"); + assert_eq!(TypedMap::api_version(&()), "v1"); + assert_eq!(TypedMap::group(&()), ""); + assert_eq!(TypedMap::plural(&()), "configmaps"); + } + + #[test] + fn test_parse_secret_default() { + TypedSecret::default(); + assert_eq!(TypedSecret::kind(&()), "Secret"); + assert_eq!(TypedSecret::api_version(&()), "v1"); + assert_eq!(TypedSecret::group(&()), ""); + assert_eq!(TypedSecret::plural(&()), "secrets"); + } +} diff --git a/kube/src/lib.rs b/kube/src/lib.rs index af8f7490b..73c3fd47e 100644 --- a/kube/src/lib.rs +++ b/kube/src/lib.rs @@ -165,6 +165,10 @@ cfg_error! { #[cfg_attr(docsrs, doc(cfg(feature = "derive")))] pub use kube_derive::CustomResource; +#[cfg(feature = "derive")] +#[cfg_attr(docsrs, doc(cfg(feature = "derive")))] +pub use kube_derive::Resource; + /// Re-exports from `kube-runtime` #[cfg(feature = "runtime")] #[cfg_attr(docsrs, doc(cfg(feature = "runtime")))]