From 52b3fd8758a5bfcb1628abad21802e7d3c774b89 Mon Sep 17 00:00:00 2001 From: Eirik A Date: Mon, 18 Mar 2024 16:09:18 +0000 Subject: [PATCH] client_ext for `Client::get` and `Client::list` (#1375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MVP Client Extensions AKA Api calls without `Api`. To avoid overreaching in this PR we only deal with `.get` and `.list` for cluster and namespace scoped resources. An example node_watcher uses this (to show we can avoid the clone and api construction) for `.list_all`. Signed-off-by: clux * one line doc fixes + basic integration test Signed-off-by: clux * fix lint + add missing cluster list + remove unnecessary convenience Signed-off-by: clux * reorder methods so they are listed in order of verb first Signed-off-by: clux * add docs and more tests Signed-off-by: clux * Move request scope into a separate parameter * Support dynamic resource scopes * rename to more ergonomic names and update docs Signed-off-by: clux * tests + an import idea Signed-off-by: clux * add some docs to distinguish the ext method block from the ctor block Signed-off-by: clux * fix doc tests Signed-off-by: clux * ugh a special case in find broke fmt for me Signed-off-by: clux * properly fix unused import warning in discovery was originally afraid to remove this since it's a pub re-export but it's only pub for this module, the root `mod.rs` does not re-export so have moved the only import to where it is needed Signed-off-by: clux * no need to confuse features around client ext tests these tests should run if the parent module is included Signed-off-by: clux * better docs for client-ext related stuff - docsrs feature limiters, for best effort help - plus a couple of broken links Signed-off-by: clux * no need for inner docsrs doccfg attrs Signed-off-by: clux --------- Signed-off-by: clux Co-authored-by: Natalie Klestrup Röijezon --- examples/Cargo.toml | 4 +- examples/node_watcher.rs | 9 +- kube-client/Cargo.toml | 3 +- kube-client/src/client/client_ext.rs | 259 ++++++++++++++++++++++++++ kube-client/src/client/mod.rs | 23 ++- kube-client/src/config/mod.rs | 2 +- kube-client/src/discovery/apigroup.rs | 3 +- kube-client/src/error.rs | 6 +- kube-client/src/lib.rs | 2 +- kube/Cargo.toml | 1 + 10 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 kube-client/src/client/client_ext.rs diff --git a/examples/Cargo.toml b/examples/Cargo.toml index ecc070bb3..760003f2a 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -16,8 +16,8 @@ release = false [features] default = ["rustls-tls", "kubederive", "ws", "latest", "socks5", "runtime", "refresh"] kubederive = ["kube/derive"] -openssl-tls = ["kube/client", "kube/openssl-tls"] -rustls-tls = ["kube/client", "kube/rustls-tls"] +openssl-tls = ["kube/client", "kube/openssl-tls", "kube/unstable-client"] +rustls-tls = ["kube/client", "kube/rustls-tls", "kube/unstable-client"] runtime = ["kube/runtime", "kube/unstable-runtime"] socks5 = ["kube/socks5"] refresh = ["kube/oauth", "kube/oidc"] diff --git a/examples/node_watcher.rs b/examples/node_watcher.rs index 1391abe11..a27756a26 100644 --- a/examples/node_watcher.rs +++ b/examples/node_watcher.rs @@ -2,8 +2,8 @@ use futures::{pin_mut, TryStreamExt}; use k8s_openapi::api::core::v1::{Event, Node}; use kube::{ api::{Api, ListParams, ResourceExt}, + client::{scope, Client}, runtime::{watcher, WatchStreamExt}, - Client, }; use tracing::*; @@ -11,7 +11,6 @@ use tracing::*; async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); let client = Client::try_default().await?; - let events: Api = Api::all(client.clone()); let nodes: Api = Api::all(client.clone()); let use_watchlist = std::env::var("WATCHLIST").map(|s| s == "1").unwrap_or(false); @@ -25,13 +24,13 @@ async fn main() -> anyhow::Result<()> { pin_mut!(obs); while let Some(n) = obs.try_next().await? { - check_for_node_failures(&events, n).await?; + check_for_node_failures(&client, n).await?; } Ok(()) } // A simple node problem detector -async fn check_for_node_failures(events: &Api, o: Node) -> anyhow::Result<()> { +async fn check_for_node_failures(client: &Client, o: Node) -> anyhow::Result<()> { let name = o.name_any(); // Nodes often modify a lot - only print broken nodes if let Some(true) = o.spec.unwrap().unschedulable { @@ -52,7 +51,7 @@ async fn check_for_node_failures(events: &Api, o: Node) -> anyhow::Result // Find events related to this node let opts = ListParams::default().fields(&format!("involvedObject.kind=Node,involvedObject.name={name}")); - let evlist = events.list(&opts).await?; + let evlist = client.list::(&opts, &scope::Cluster).await?; for e in evlist { warn!("Node event: {:?}", serde_json::to_string_pretty(&e)?); } diff --git a/kube-client/Cargo.toml b/kube-client/Cargo.toml index 3949aca0a..aa757bb6f 100644 --- a/kube-client/Cargo.toml +++ b/kube-client/Cargo.toml @@ -28,12 +28,13 @@ jsonpatch = ["kube-core/jsonpatch"] admission = ["kube-core/admission"] config = ["__non_core", "pem", "home"] socks5 = ["hyper-socks2"] +unstable-client = [] # private feature sets; do not use __non_core = ["tracing", "serde_yaml", "base64"] [package.metadata.docs.rs] -features = ["client", "rustls-tls", "openssl-tls", "ws", "oauth", "oidc", "jsonpatch", "admission", "k8s-openapi/latest", "socks5"] +features = ["client", "rustls-tls", "openssl-tls", "ws", "oauth", "oidc", "jsonpatch", "admission", "k8s-openapi/latest", "socks5", "unstable-client"] # Define the configuration attribute `docsrs`. Used to enable `doc_cfg` feature. rustdoc-args = ["--cfg", "docsrs"] diff --git a/kube-client/src/client/client_ext.rs b/kube-client/src/client/client_ext.rs new file mode 100644 index 000000000..48704ea07 --- /dev/null +++ b/kube-client/src/client/client_ext.rs @@ -0,0 +1,259 @@ +use crate::{Client, Error, Result}; +use k8s_openapi::api::core::v1::Namespace as k8sNs; +use kube_core::{ + object::ObjectList, + params::{GetParams, ListParams}, + request::Request, + ClusterResourceScope, DynamicResourceScope, NamespaceResourceScope, Resource, +}; +use serde::{de::DeserializeOwned, Serialize}; +use std::fmt::Debug; + +/// A marker trait to indicate cluster-wide operations are available +trait ClusterScope {} +/// A marker trait to indicate namespace-scoped operations are available +trait NamespaceScope {} + +// k8s_openapi scopes get implementations for free +impl ClusterScope for ClusterResourceScope {} +impl NamespaceScope for NamespaceResourceScope {} +// our DynamicResourceScope can masquerade as either +impl NamespaceScope for DynamicResourceScope {} +impl ClusterScope for DynamicResourceScope {} + +/// How to get the url for a collection +/// +/// Pick one of `kube::client::Cluster` or `kube::client::Namespace`. +pub trait CollectionUrl { + fn url_path(&self) -> String; +} + +/// How to get the url for an object +/// +/// Pick one of `kube::client::Cluster` or `kube::client::Namespace`. +pub trait ObjectUrl { + fn url_path(&self) -> String; +} + +/// Marker type for cluster level queries +pub struct Cluster; +/// Namespace newtype for namespace level queries +/// +/// You can create this directly, or convert `From` a `String` / `&str`, or `TryFrom` an `k8s_openapi::api::core::v1::Namespace` +pub struct Namespace(String); + +/// Scopes for `unstable-client` [`Client#impl-Client`] extension methods +pub mod scope { + pub use super::{Cluster, Namespace}; +} + +// All objects can be listed cluster-wide +impl CollectionUrl for Cluster +where + K: Resource, + K::DynamicType: Default, +{ + fn url_path(&self) -> String { + K::url_path(&K::DynamicType::default(), None) + } +} + +// Only cluster-scoped objects can be named globally +impl ObjectUrl for Cluster +where + K: Resource, + K::DynamicType: Default, + K::Scope: ClusterScope, +{ + fn url_path(&self) -> String { + K::url_path(&K::DynamicType::default(), None) + } +} + +// Only namespaced objects can be accessed via namespace +impl CollectionUrl for Namespace +where + K: Resource, + K::DynamicType: Default, + K::Scope: NamespaceScope, +{ + fn url_path(&self) -> String { + K::url_path(&K::DynamicType::default(), Some(&self.0)) + } +} + +impl ObjectUrl for Namespace +where + K: Resource, + K::DynamicType: Default, + K::Scope: NamespaceScope, +{ + fn url_path(&self) -> String { + K::url_path(&K::DynamicType::default(), Some(&self.0)) + } +} + +// can be created from a complete native object +impl TryFrom<&k8sNs> for Namespace { + type Error = NamespaceError; + + fn try_from(ns: &k8sNs) -> Result { + if let Some(n) = &ns.meta().name { + Ok(Namespace(n.to_owned())) + } else { + Err(NamespaceError::MissingName) + } + } +} +// and from literals + owned strings +impl From<&str> for Namespace { + fn from(ns: &str) -> Namespace { + Namespace(ns.to_owned()) + } +} +impl From for Namespace { + fn from(ns: String) -> Namespace { + Namespace(ns) + } +} + +#[derive(thiserror::Error, Debug)] +/// Failures to infer a namespace +pub enum NamespaceError { + /// MissingName + #[error("Missing Namespace Name")] + MissingName, +} + +/// Generic client extensions for the `unstable-client` feature +/// +/// These methods allow users to query across a wide-array of resources without needing +/// to explicitly create an [`Api`](crate::Api) for each one of them. +/// +/// ## Usage +/// 1. Create a [`Client`] +/// 2. Specify the [`scope`] you are querying at via [`Cluster`] or [`Namespace`] as args +/// 3. Specify the resource type you are using for serialization (e.g. a top level k8s-openapi type) +/// +/// ## Example +/// +/// ```no_run +/// # use k8s_openapi::api::core::v1::Pod; +/// # use k8s_openapi::api::core::v1::Service; +/// # use kube::client::scope::{Cluster, Namespace}; +/// # use kube::{ResourceExt, api::ListParams}; +/// # async fn wrapper() -> Result<(), Box> { +/// # let client: kube::Client = todo!(); +/// let lp = ListParams::default(); +/// // List at Cluster level for Pod resource: +/// for pod in client.list::(&lp, &Cluster).await? { +/// println!("Found pod {} in {}", pod.name_any(), pod.namespace().unwrap()); +/// } +/// // Namespaced Get for Service resource: +/// let svc = client.get::("kubernetes", &Namespace::from("default")).await?; +/// assert_eq!(svc.name_unchecked(), "kubernetes"); +/// # Ok(()) +/// # } +/// ``` +impl Client { + /// Get a single instance of a `Resource` implementing type `K` at the specified scope. + /// + /// ```no_run + /// # use k8s_openapi::api::rbac::v1::ClusterRole; + /// # use k8s_openapi::api::core::v1::Service; + /// # use kube::client::scope::{Cluster, Namespace}; + /// # use kube::{ResourceExt, api::GetParams}; + /// # async fn wrapper() -> Result<(), Box> { + /// # let client: kube::Client = todo!(); + /// let cr = client.get::("cluster-admin", &Cluster).await?; + /// assert_eq!(cr.name_unchecked(), "cluster-admin"); + /// let svc = client.get::("kubernetes", &Namespace::from("default")).await?; + /// assert_eq!(svc.name_unchecked(), "kubernetes"); + /// # Ok(()) + /// # } + /// ``` + pub async fn get(&self, name: &str, scope: &impl ObjectUrl) -> Result + where + K: Resource + Serialize + DeserializeOwned + Clone + Debug, + ::DynamicType: Default, + { + let mut req = Request::new(scope.url_path()) + .get(name, &GetParams::default()) + .map_err(Error::BuildRequest)?; + req.extensions_mut().insert("get"); + self.request::(req).await + } + + /// List instances of a `Resource` implementing type `K` at the specified scope. + /// + /// ```no_run + /// # use k8s_openapi::api::core::v1::Pod; + /// # use k8s_openapi::api::core::v1::Service; + /// # use kube::client::scope::{Cluster, Namespace}; + /// # use kube::{ResourceExt, api::ListParams}; + /// # async fn wrapper() -> Result<(), Box> { + /// # let client: kube::Client = todo!(); + /// let lp = ListParams::default(); + /// for pod in client.list::(&lp, &Cluster).await? { + /// println!("Found pod {} in {}", pod.name_any(), pod.namespace().unwrap()); + /// } + /// for svc in client.list::(&lp, &Namespace::from("default")).await? { + /// println!("Found service {}", svc.name_any()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn list(&self, lp: &ListParams, scope: &impl CollectionUrl) -> Result> + where + K: Resource + Serialize + DeserializeOwned + Clone + Debug, + ::DynamicType: Default, + { + let mut req = Request::new(scope.url_path()) + .list(lp) + .map_err(Error::BuildRequest)?; + req.extensions_mut().insert("list"); + self.request::>(req).await + } +} + +#[cfg(test)] +mod test { + use super::{ + scope::{Cluster, Namespace}, + Client, ListParams, + }; + use kube_core::ResourceExt; + + #[tokio::test] + #[ignore = "needs cluster (will list/get namespaces, pods, jobs, svcs, clusterroles)"] + async fn client_ext_list_get_pods_svcs() -> Result<(), Box> { + use k8s_openapi::api::{ + batch::v1::Job, + core::v1::{Namespace as k8sNs, Pod, Service}, + rbac::v1::ClusterRole, + }; + + let client = Client::try_default().await?; + let lp = ListParams::default(); + // cluster-scoped list + for ns in client.list::(&lp, &Cluster).await? { + // namespaced list + for p in client.list::(&lp, &Namespace::try_from(&ns)?).await? { + println!("Found pod {} in {}", p.name_any(), ns.name_any()); + } + } + // across-namespace list + for j in client.list::(&lp, &Cluster).await? { + println!("Found job {} in {}", j.name_any(), j.namespace().unwrap()); + } + // namespaced get + let default: Namespace = "default".into(); + let svc = client.get::("kubernetes", &default).await?; + assert_eq!(svc.name_unchecked(), "kubernetes"); + // global get + let ca = client.get::("cluster-admin", &Cluster).await?; + assert_eq!(ca.name_unchecked(), "cluster-admin"); + + Ok(()) + } +} diff --git a/kube-client/src/client/mod.rs b/kube-client/src/client/mod.rs index 83d87e74a..6485d5896 100644 --- a/kube-client/src/client/mod.rs +++ b/kube-client/src/client/mod.rs @@ -31,6 +31,12 @@ mod body; mod builder; // Add `into_stream()` to `http::Body` use body::BodyStreamExt; +#[cfg_attr(docsrs, doc(cfg(feature = "unstable-client")))] +#[cfg(feature = "unstable-client")] +mod client_ext; +#[cfg_attr(docsrs, doc(cfg(feature = "unstable-client")))] +#[cfg(feature = "unstable-client")] +pub use client_ext::scope; mod config_ext; pub use auth::Error as AuthError; pub use config_ext::ConfigExt; @@ -69,6 +75,11 @@ pub struct Client { default_ns: String, } +/// Constructors and low-level api interfaces. +/// +/// Most users only need [`Client::try_default`] or [`Client::new`] from this block. +/// +/// The many various lower level interfaces here are for more advanced use-cases with specific requirements. impl Client { /// Create a [`Client`] using a custom `Service` stack. /// @@ -123,6 +134,14 @@ impl Client { /// /// Will fail if neither configuration could be loaded. /// + /// ```rust + /// # async fn doc() -> Result<(), Box> { + /// # use kube::Client; + /// let client = Client::try_default().await?; + /// # Ok(()) + /// # } + /// ``` + /// /// If you already have a [`Config`] then use [`Client::try_from`](Self::try_from) /// instead. pub async fn try_default() -> Result { @@ -460,7 +479,9 @@ fn handle_api_errors(text: &str, s: StatusCode) -> Result<()> { impl TryFrom for Client { type Error = Error; - /// Builds a default [`Client`] from a [`Config`], see [`ClientBuilder`] if more customization is required + /// Builds a default [`Client`] from a [`Config`]. + /// + /// See [`ClientBuilder`] or [`Client::new`] if more customization is required fn try_from(config: Config) -> Result { Ok(ClientBuilder::try_from(config)?.build()) } diff --git a/kube-client/src/config/mod.rs b/kube-client/src/config/mod.rs index 3d9e2be77..a60935960 100644 --- a/kube-client/src/config/mod.rs +++ b/kube-client/src/config/mod.rs @@ -122,7 +122,7 @@ pub enum LoadDataError { /// Prefer [`Config::infer`] unless you have particular issues, and avoid manually managing /// the data in this struct unless you have particular needs. It exists to be consumed by the [`Client`][crate::Client]. /// -/// If you are looking to parse the kubeconfig found in a user's home directory see [`Kubeconfig`](crate::config::Kubeconfig). +/// If you are looking to parse the kubeconfig found in a user's home directory see [`Kubeconfig`]. #[cfg_attr(docsrs, doc(cfg(feature = "config")))] #[derive(Debug, Clone)] pub struct Config { diff --git a/kube-client/src/discovery/apigroup.rs b/kube-client/src/discovery/apigroup.rs index 42f2f1740..d7a7557a3 100644 --- a/kube-client/src/discovery/apigroup.rs +++ b/kube-client/src/discovery/apigroup.rs @@ -1,7 +1,7 @@ use super::parse::{self, GroupVersionData}; use crate::{error::DiscoveryError, Client, Error, Result}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::{APIGroup, APIVersions}; -pub use kube_core::discovery::{verbs, ApiCapabilities, ApiResource, Scope}; +pub use kube_core::discovery::{ApiCapabilities, ApiResource}; use kube_core::{ gvk::{GroupVersion, GroupVersionKind, ParseGroupVersionError}, Version, @@ -327,6 +327,7 @@ impl ApiGroup { #[cfg(test)] mod tests { use super::*; + use kube_core::discovery::Scope; #[test] fn test_resources_by_stability() { diff --git a/kube-client/src/error.rs b/kube-client/src/error.rs index bc15beb2c..09aff9164 100644 --- a/kube-client/src/error.rs +++ b/kube-client/src/error.rs @@ -1,9 +1,9 @@ -//! Error handling in [`kube`][crate] +//! Error handling and error types use thiserror::Error; pub use kube_core::ErrorResponse; -/// Possible errors when working with [`kube`][crate] +/// Possible errors from the [`Client`](crate::Client) #[cfg_attr(docsrs, doc(cfg(any(feature = "config", feature = "client"))))] #[derive(Error, Debug)] pub enum Error { @@ -89,7 +89,7 @@ pub enum Error { } #[derive(Error, Debug)] -/// Possible errors when using API discovery +/// Possible errors when using API [discovery](crate::discovery) pub enum DiscoveryError { /// Invalid GroupVersion #[error("Invalid GroupVersion: {0}")] diff --git a/kube-client/src/lib.rs b/kube-client/src/lib.rs index d662ff4fa..427b4e25f 100644 --- a/kube-client/src/lib.rs +++ b/kube-client/src/lib.rs @@ -59,7 +59,7 @@ //! - [`Client`](crate::client) for the extensible Kubernetes client //! - [`Config`](crate::config) for the Kubernetes config abstraction //! - [`Api`](crate::Api) for the generic api methods available on Kubernetes resources -//! - [k8s-openapi](https://docs.rs/k8s-openapi/*/k8s_openapi/) for how to create typed kubernetes objects directly +//! - [k8s-openapi](https://docs.rs/k8s-openapi) for how to create typed kubernetes objects directly #![cfg_attr(docsrs, feature(doc_cfg))] #![deny(missing_docs)] #![forbid(unsafe_code)] diff --git a/kube/Cargo.toml b/kube/Cargo.toml index 8dda505ac..9bd2df168 100644 --- a/kube/Cargo.toml +++ b/kube/Cargo.toml @@ -36,6 +36,7 @@ admission = ["kube-core/admission"] derive = ["kube-derive", "kube-core/schema"] runtime = ["kube-runtime"] unstable-runtime = ["kube-runtime/unstable-runtime"] +unstable-client = ["kube-client/unstable-client"] socks5 = ["kube-client/socks5"] [package.metadata.docs.rs]