From a2c7aefb4c14569b0d3ad3f81fe4dfda5120f209 Mon Sep 17 00:00:00 2001 From: Eirik A Date: Mon, 31 May 2021 17:43:23 +0100 Subject: [PATCH] clean up api::discovery module - for #523 (#538) * clean up api::discovery module - for #523 - moves the modules from kube::client to kube::api - introduces easier way to get recommended_resources and recommended_kind from an `ApiGroup` - restructures file for readability (internal Version sorting quirks documented and tested) - renamed `ApiResourceExtras` to `ApiCapabilities` - renamed `ApiGroup::group` to `ApiGroup::get` - added support for `Discovery::single` which avoids having to deal with the `HashMap` and returns a straight `ApiGroup` (making the recommended doc examples more compelling) - documents the version sorting a bit better - renamed `ApiGroup::preferred_version_or_guess` to `ApiGroup::preferred_version_or_latest` and referred to the version policy - separated querying to helpers on `ApiGroup` - moved sorting to `ApiGroup` construction part * clippy * changelog * promote discovery to its own module * factor out version logic into own private module * add some docs + small renames `resources_by_version` -> `versioned_resources` to match `recommended_resources`. few more doc links * remove stray todo and add back kube::client::Status * add better entry point for the cheaper single case * fix tests * restructure completely again for cache/oneshot distinction actually many legit use cases here, so made it kind of nice. finally, this allows us to deprecate old ApiResource from_apiresource as well as old Client listers (which we use internally). possibly some more cleanups incoming. * clippy * split discovery into 3 files * discovery error type * fix tests * rename openapi mod to parse mod * revert premature deprecation * document changes * make DiscoveryError work on config only feat + fix rustfmt makefile * resolve last questions --- CHANGELOG.md | 8 +- Makefile | 2 +- examples/dynamic_api.rs | 28 +-- examples/dynamic_watcher.rs | 20 +- kube-core/src/api_resource.rs | 102 --------- kube-core/src/discovery.rs | 109 +++++++++ kube-core/src/dynamic.rs | 5 +- kube-core/src/error.rs | 4 + kube-core/src/gvk.rs | 59 ++++- kube-core/src/lib.rs | 5 +- kube-core/src/object.rs | 2 +- kube/src/api/subresource.rs | 4 +- kube/src/client/discovery.rs | 399 --------------------------------- kube/src/client/mod.rs | 18 +- kube/src/config/mod.rs | 3 +- kube/src/discovery/apigroup.rs | 269 ++++++++++++++++++++++ kube/src/discovery/mod.rs | 159 +++++++++++++ kube/src/discovery/oneshot.rs | 104 +++++++++ kube/src/discovery/parse.rs | 80 +++++++ kube/src/discovery/version.rs | 150 +++++++++++++ kube/src/error.rs | 24 ++ kube/src/lib.rs | 3 + 22 files changed, 1005 insertions(+), 552 deletions(-) delete mode 100644 kube-core/src/api_resource.rs create mode 100644 kube-core/src/discovery.rs delete mode 100644 kube/src/client/discovery.rs create mode 100644 kube/src/discovery/apigroup.rs create mode 100644 kube/src/discovery/mod.rs create mode 100644 kube/src/discovery/oneshot.rs create mode 100644 kube/src/discovery/parse.rs create mode 100644 kube/src/discovery/version.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e867c5e5d..c08b791bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ UNRELEASED =================== * see https://github.com/clux/kube-rs/compare/0.55.0...master + * `kube`: added `Api::default_namespaced` - #209 via #534 + * `kube`: added `config` feature - #533 via #535 + * `kube`: BREAKING: moved `client::discovery` module to `kube::discovery` and rewritten module #523 + - `discovery`: added `oneshot` helpers for quick selection of recommended resources / kinds #523 + - `discovery`: moved `ApiResource` and `ApiCapabilities` (result of discovery) to `kube_core::discovery` + - BREAKING: removed internal `ApiResource::from_apiresource` 0.55.0 / 2021-05-21 =================== @@ -10,7 +16,7 @@ UNRELEASED * `kube`: `api` `discovery` module now uses a new `ApiResource` struct [#495](https://github.com/clux/kube-rs/issues/495) + [#482](https://github.com/clux/kube-rs/issues/482) * `kube`: `api` BREAKING: `DynamicObject` + `Object` now takes an `ApiResource` rather than a `GroupVersionKind` * `kube`: `api` BREAKING: `discovery` module's `Group` renamed to `ApiGroup` - * `kube`: `client` BREAKING: `kube::client::Status` moved to `kube::core::Status` + * `kube`: `client` BREAKING: `kube::client::Status` moved to `kube::core::Status` (accidental, re-adding in 0.56) * `kube-core` crate factored out of `kube` to reduce dependencies - [#516](https://github.com/clux/kube-rs/issues/516) via [#517](https://github.com/clux/kube-rs/issues/517) + [#519](https://github.com/clux/kube-rs/issues/519) + [#522](https://github.com/clux/kube-rs/issues/522) + [#528](https://github.com/clux/kube-rs/issues/528) + [#530](https://github.com/clux/kube-rs/issues/530) * `kube`: `kube::Service` removed to allow `kube::Client` to take an abritrary `Service>` - [#532](https://github.com/clux/kube-rs/issues/532) diff --git a/Makefile b/Makefile index 4f459f374..21c61eed8 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ clippy: fmt: #rustup component add rustfmt --toolchain nightly - cargo +nightly fmt + rustfmt +nightly --edition 2018 **/*/*.rs doc: RUSTDOCFLAGS="--cfg docsrs" cargo +nightly doc --lib --workspace --features=derive,ws,oauth,jsonpatch --open diff --git a/examples/dynamic_api.rs b/examples/dynamic_api.rs index 09c7c8487..6afab3300 100644 --- a/examples/dynamic_api.rs +++ b/examples/dynamic_api.rs @@ -3,10 +3,8 @@ use kube::{ api::{Api, DynamicObject, ResourceExt}, - client::{ - discovery::{verbs, Discovery, Scope}, - Client, - }, + discovery::{verbs, Discovery, Scope}, + Client, }; use log::info; @@ -15,31 +13,25 @@ async fn main() -> anyhow::Result<()> { std::env::set_var("RUST_LOG", "info,kube=debug"); env_logger::init(); let client = Client::try_default().await?; - - let v = client.apiserver_version().await?; - info!("api version: {:?}", v); - let ns_filter = std::env::var("NAMESPACE").ok(); - let discovery = Discovery::new(&client).await?; - + let discovery = Discovery::new(client.clone()).run().await?; for group in discovery.groups() { - let ver = group.preferred_version_or_guess(); - for (api_res, extras) in group.resources_by_version(ver) { - if !extras.supports_operation(verbs::LIST) { + for (ar, caps) in group.recommended_resources() { + if !caps.supports_operation(verbs::LIST) { continue; } - let api: Api = if extras.scope == Scope::Namespaced { + let api: Api = if caps.scope == Scope::Namespaced { if let Some(ns) = &ns_filter { - Api::namespaced_with(client.clone(), ns, &api_res) + Api::namespaced_with(client.clone(), ns, &ar) } else { - Api::all_with(client.clone(), &api_res) + Api::all_with(client.clone(), &ar) } } else { - Api::all_with(client.clone(), &api_res) + Api::all_with(client.clone(), &ar) }; - info!("{}/{} : {}", group.name(), ver, api_res.kind); + info!("{}/{} : {}", group.name(), ar.version, ar.kind); let list = api.list(&Default::default()).await?; for item in list.items { diff --git a/examples/dynamic_watcher.rs b/examples/dynamic_watcher.rs index a903e8dd5..85a841870 100644 --- a/examples/dynamic_watcher.rs +++ b/examples/dynamic_watcher.rs @@ -1,7 +1,7 @@ use futures::prelude::*; use kube::{ - api::{ApiResource, DynamicObject, GroupVersionKind, ListParams, ResourceExt}, - Api, Client, + api::{DynamicObject, GroupVersionKind, ListParams, ResourceExt}, + discovery, Api, Client, }; use kube_runtime::{utils::try_flatten_applied, watcher}; use std::env; @@ -19,19 +19,11 @@ async fn main() -> anyhow::Result<()> { // Turn them into a GVK let gvk = GroupVersionKind::gvk(&group, &version, &kind); - let mut api_resource = ApiResource::from_gvk(&gvk); + // Use API discovery to identify more information about the type (like its plural) + let (ar, _caps) = discovery::pinned_kind(&client, &gvk).await?; - if let Some(resource) = env::var("RESOURCE").ok() { - api_resource.plural = resource; - } else { - println!( - "Using inferred plural name (use RESOURCE to override): {}", - api_resource.plural - ); - } - - // Use them in an Api with the GVK as its DynamicType - let api = Api::::all_with(client, &api_resource); + // Use the discovered kind in an Api with the ApiResource as its DynamicType + let api = Api::::all_with(client, &ar); // Fully compatible with kube-runtime let watcher = watcher(api, ListParams::default()); diff --git a/kube-core/src/api_resource.rs b/kube-core/src/api_resource.rs deleted file mode 100644 index 3eef76187..000000000 --- a/kube-core/src/api_resource.rs +++ /dev/null @@ -1,102 +0,0 @@ -use crate::{gvk::GroupVersionKind, resource::Resource}; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::APIResource; - -/// Information about a Kubernetes API resource -/// -/// Enough information to use it as a `Resource` -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub struct ApiResource { - /// Resource group, empty for core group. - pub group: String, - /// group version - pub version: String, - /// apiVersion of the resource (v1 for core group, - /// groupName/groupVersions for other). - pub api_version: String, - /// Singular PascalCase name of the resource - pub kind: String, - /// Plural name of the resource - pub plural: String, -} - -impl ApiResource { - /// Creates ApiResource from `meta::v1::APIResource` instance. - /// - /// `APIResource` objects can be extracted from [`Client::list_api_group_resources`](crate::Client::list_api_group_resources). - /// If it does not specify version and/or group, they will be taken from `group_version` - /// (otherwise the second parameter is ignored). - /// - /// ### Example usage: - /// ``` - /// use kube::api::{ApiResource, Api, DynamicObject}; - /// # async fn scope(client: kube::Client) -> Result<(), Box> { - /// let apps = client.list_api_group_resources("apps/v1").await?; - /// for ar in &apps.resources { - /// let resource = ApiResource::from_apiresource(ar, &apps.group_version); - /// dbg!(&resource); - /// let api: Api = Api::namespaced_with(client.clone(), "default", &resource); - /// } - /// # Ok(()) - /// # } - /// ``` - pub fn from_apiresource(ar: &APIResource, group_version: &str) -> Self { - let gvsplit = group_version.splitn(2, '/').collect::>(); - let (default_group, default_version) = match *gvsplit.as_slice() { - [g, v] => (g, v), // standard case - [v] => ("", v), // core v1 case - _ => unreachable!(), - }; - let group = ar.group.clone().unwrap_or_else(|| default_group.into()); - let version = ar.version.clone().unwrap_or_else(|| default_version.into()); - let kind = ar.kind.to_string(); - let api_version = if group.is_empty() { - version.clone() - } else { - format!("{}/{}", group, version) - }; - let plural = ar.name.clone(); - ApiResource { - group, - version, - api_version, - kind, - plural, - } - } - - /// Creates ApiResource by type-erasing another Resource - pub fn erase(dt: &K::DynamicType) -> Self { - ApiResource { - group: K::group(dt).to_string(), - version: K::version(dt).to_string(), - api_version: K::api_version(dt).to_string(), - kind: K::kind(dt).to_string(), - plural: K::plural(dt).to_string(), - } - } - - /// Creates ApiResource from group, version and kind. - /// # Warning - /// This function has to **guess** resource plural name. - /// While it makes it best to guess correctly, sometimes it can - /// be wrong, and using returned ApiResource will lead to incorrect - /// api requests. - pub fn from_gvk(gvk: &GroupVersionKind) -> Self { - ApiResource::from_gvk_with_plural(gvk, &crate::resource::to_plural(&gvk.kind.to_ascii_lowercase())) - } - - /// Creates ApiResource from group, version, kind and plural name. - pub fn from_gvk_with_plural(gvk: &GroupVersionKind, plural: &str) -> Self { - let api_version = match gvk.group.as_str() { - "" => gvk.version.clone(), - _ => format!("{}/{}", gvk.group, gvk.version), - }; - ApiResource { - group: gvk.group.clone(), - version: gvk.version.clone(), - api_version, - kind: gvk.kind.clone(), - plural: plural.to_string(), - } - } -} diff --git a/kube-core/src/discovery.rs b/kube-core/src/discovery.rs new file mode 100644 index 000000000..3c78238c9 --- /dev/null +++ b/kube-core/src/discovery.rs @@ -0,0 +1,109 @@ +//! Type information structs for API discovery +use crate::{gvk::GroupVersionKind, resource::Resource}; + +/// Information about a Kubernetes API resource +/// +/// Enough information to use it like a `Resource` by passing it to the dynamic `Api` +/// constructors like `Api::all_with` and `Api::namespaced_with`. +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct ApiResource { + /// Resource group, empty for core group. + pub group: String, + /// group version + pub version: String, + /// apiVersion of the resource (v1 for core group, + /// groupName/groupVersions for other). + pub api_version: String, + /// Singular PascalCase name of the resource + pub kind: String, + /// Plural name of the resource + pub plural: String, +} + +impl ApiResource { + /// Creates an ApiResource by type-erasing a Resource + pub fn erase(dt: &K::DynamicType) -> Self { + ApiResource { + group: K::group(dt).to_string(), + version: K::version(dt).to_string(), + api_version: K::api_version(dt).to_string(), + kind: K::kind(dt).to_string(), + plural: K::plural(dt).to_string(), + } + } + + /// Creates an ApiResource from group, version, kind and plural name. + pub fn from_gvk_with_plural(gvk: &GroupVersionKind, plural: &str) -> Self { + ApiResource { + api_version: gvk.api_version(), + group: gvk.group.clone(), + version: gvk.version.clone(), + kind: gvk.kind.clone(), + plural: plural.to_string(), + } + } + + /// Creates an ApiResource from group, version and kind. + /// + /// # Warning + /// This function will **guess** the resource plural name. + /// Usually, this is ok, but for CRDs with complex pluralisations it can fail. + /// If you are getting your values from `kube_derive` use the generated method for giving you an [`ApiResource`]. + /// Otherwise consider using [`ApiResource::from_gvk_with_plural`](crate::discovery::ApiResource::from_gvk_with_plural) + /// to explicitly set the plural, or run api discovery on it via `kube::discovery`. + pub fn from_gvk(gvk: &GroupVersionKind) -> Self { + ApiResource::from_gvk_with_plural(gvk, &crate::resource::to_plural(&gvk.kind.to_ascii_lowercase())) + } +} + + +/// Resource scope +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub enum Scope { + /// Objects are global + Cluster, + /// Each object lives in namespace. + Namespaced, +} + +/// Rbac verbs for ApiCapabilities +pub mod verbs { + /// Create a resource + pub const CREATE: &str = "create"; + /// Get single resource + pub const GET: &str = "get"; + /// List objects + pub const LIST: &str = "list"; + /// Watch for objects changes + pub const WATCH: &str = "watch"; + /// Delete single object + pub const DELETE: &str = "delete"; + /// Delete multiple objects at once + pub const DELETE_COLLECTION: &str = "deletecollection"; + /// Update an object + pub const UPDATE: &str = "update"; + /// Patch an object + pub const PATCH: &str = "patch"; +} + +/// Contains the capabilities of an API resource +#[derive(Debug, Clone)] +pub struct ApiCapabilities { + /// Scope of the resource + pub scope: Scope, + /// Available subresources. + /// + /// Please note that returned ApiResources are not standalone resources. + /// Their name will be of form `subresource_name`, not `resource_name/subresource_name`. + /// To work with subresources, use `Request` methods for now. + pub subresources: Vec<(ApiResource, ApiCapabilities)>, + /// Supported operations on this resource + pub operations: Vec, +} + +impl ApiCapabilities { + /// Checks that given verb is supported on this resource. + pub fn supports_operation(&self, operation: &str) -> bool { + self.operations.iter().any(|op| op == operation) + } +} diff --git a/kube-core/src/dynamic.rs b/kube-core/src/dynamic.rs index 97685313b..6fabd09bf 100644 --- a/kube-core/src/dynamic.rs +++ b/kube-core/src/dynamic.rs @@ -2,7 +2,7 @@ //! //! For concrete usage see [examples prefixed with dynamic_](https://github.com/clux/kube-rs/tree/master/examples). -pub use crate::api_resource::ApiResource; +pub use crate::discovery::ApiResource; use crate::{metadata::TypeMeta, resource::Resource}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use std::borrow::Cow; @@ -87,8 +87,7 @@ impl Resource for DynamicObject { #[cfg(test)] mod test { use crate::{ - api_resource::ApiResource, - dynamic::DynamicObject, + dynamic::{ApiResource, DynamicObject}, gvk::GroupVersionKind, params::{Patch, PatchParams, PostParams}, request::Request, diff --git a/kube-core/src/error.rs b/kube-core/src/error.rs index 10a4982d9..8e0b5bb31 100644 --- a/kube-core/src/error.rs +++ b/kube-core/src/error.rs @@ -15,6 +15,10 @@ pub enum Error { /// Http based error #[error("HttpError: {0}")] HttpError(#[from] http::Error), + + /// Invalid GroupVersion + #[error("Invalid GroupVersion: {0}")] + InvalidGroupVersion(String), } /// An error response from the API. diff --git a/kube-core/src/gvk.rs b/kube-core/src/gvk.rs index 6bef36de3..4d2f2136f 100644 --- a/kube-core/src/gvk.rs +++ b/kube-core/src/gvk.rs @@ -1,7 +1,9 @@ //! Type information structs for dynamic resources. +use crate::Error; use serde::{Deserialize, Serialize}; +use std::str::FromStr; -/// Contains enough information to identify API Resource. +/// Core information about an API Resource. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct GroupVersionKind { /// API group @@ -13,7 +15,7 @@ pub struct GroupVersionKind { } impl GroupVersionKind { - /// Set the api group, version, and kind for a resource + /// Construct from explicit group, version, and kind pub fn gvk(group_: &str, version_: &str, kind_: &str) -> Self { let version = version_.to_string(); let group = group_.to_string(); @@ -23,6 +25,59 @@ impl GroupVersionKind { } } +/// Core information about a family of API Resources +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct GroupVersion { + /// API group + pub group: String, + /// Version + pub version: String, +} + +impl GroupVersion { + /// Construct from explicit group and version + pub fn gv(group_: &str, version_: &str) -> Self { + let version = version_.to_string(); + let group = group_.to_string(); + Self { group, version } + } +} + +impl FromStr for GroupVersion { + type Err = Error; + + fn from_str(gv: &str) -> Result { + let gvsplit = gv.splitn(2, '/').collect::>(); + let (group, version) = match *gvsplit.as_slice() { + [g, v] => (g.to_string(), v.to_string()), // standard case + [v] => ("".to_string(), v.to_string()), // core v1 case + _ => return Err(Error::InvalidGroupVersion(gv.into())), + }; + Ok(Self { group, version }) + } +} + +impl GroupVersion { + /// Generate the apiVersion string used in a kind's yaml + pub fn api_version(&self) -> String { + if self.group.is_empty() { + self.version.clone() + } else { + format!("{}/{}", self.group, self.version) + } + } +} +impl GroupVersionKind { + /// Generate the apiVersion string used in a kind's yaml + pub fn api_version(&self) -> String { + if self.group.is_empty() { + self.version.clone() + } else { + format!("{}/{}", self.group, self.version) + } + } +} + /// Represents a type-erased object resource. #[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct GroupVersionResource { diff --git a/kube-core/src/lib.rs b/kube-core/src/lib.rs index 37f25e3ae..1d3fe21f6 100644 --- a/kube-core/src/lib.rs +++ b/kube-core/src/lib.rs @@ -10,12 +10,13 @@ #[cfg(feature = "admission")] pub mod admission; -mod api_resource; +pub mod discovery; + pub mod dynamic; pub use dynamic::DynamicObject; pub mod gvk; -pub use gvk::{GroupVersionKind, GroupVersionResource}; +pub use gvk::{GroupVersion, GroupVersionKind, GroupVersionResource}; pub mod metadata; diff --git a/kube-core/src/object.rs b/kube-core/src/object.rs index 0a405a4b8..378dd7c08 100644 --- a/kube-core/src/object.rs +++ b/kube-core/src/object.rs @@ -1,6 +1,6 @@ //! Generic object and objectlist wrappers. use crate::{ - api_resource::ApiResource, + discovery::ApiResource, metadata::{ListMeta, ObjectMeta, TypeMeta}, resource::Resource, }; diff --git a/kube/src/api/subresource.rs b/kube/src/api/subresource.rs index 04afab792..c5f3ae6e9 100644 --- a/kube/src/api/subresource.rs +++ b/kube/src/api/subresource.rs @@ -158,14 +158,14 @@ where #[instrument(skip(self), level = "trace")] pub async fn logs(&self, name: &str, lp: &LogParams) -> Result { let req = self.request.logs(name, lp)?; - Ok(self.client.request_text(req).await?) + self.client.request_text(req).await } /// Fetch logs as a stream of bytes #[instrument(skip(self), level = "trace")] pub async fn log_stream(&self, name: &str, lp: &LogParams) -> Result>> { let req = self.request.logs(name, lp)?; - Ok(self.client.request_text_stream(req).await?) + self.client.request_text_stream(req).await } } diff --git a/kube/src/client/discovery.rs b/kube/src/client/discovery.rs deleted file mode 100644 index a0970d261..000000000 --- a/kube/src/client/discovery.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! High-level utilities for runtime API discovery. - -use crate::Client; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::APIResourceList; -use kube_core::dynamic::ApiResource; -use std::{cmp::Reverse, collections::HashMap}; - -/// Resource scope -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub enum Scope { - /// Objects are global - Cluster, - /// Each object lives in namespace. - Namespaced, -} - -/// Defines standard verbs -pub mod verbs { - /// Create a resource - pub const CREATE: &str = "create"; - /// Get single resource - pub const GET: &str = "get"; - /// List objects - pub const LIST: &str = "list"; - /// Watch for objects changes - pub const WATCH: &str = "watch"; - /// Delete single object - pub const DELETE: &str = "delete"; - /// Delete multiple objects at once - pub const DELETE_COLLECTION: &str = "deletecollection"; - /// Update an object - pub const UPDATE: &str = "update"; - /// Patch an object - pub const PATCH: &str = "patch"; -} - -/// Contains additional, detailed information abount API resource -#[derive(Debug, Clone)] -pub struct ApiResourceExtras { - /// Scope of the resource - pub scope: Scope, - /// Available subresources. Please note that returned ApiResources are not - /// standalone resources. Their name will be of form `subresource_name`, - /// not `resource_name/subresource_name`. - /// To work with subresources, use `Request` methods. - pub subresources: Vec<(ApiResource, ApiResourceExtras)>, - /// Supported operations on this resource - pub operations: Vec, -} - -impl ApiResourceExtras { - /// Creates ApiResourceExtras from `meta::v1::APIResourceList` instance. - /// This function correctly sets all fields except `subresources`. - /// # Panics - /// Panics if list does not contain resource named `name`. - pub fn from_apiresourcelist(list: &APIResourceList, name: &str) -> Self { - let ar = list - .resources - .iter() - .find(|r| r.name == name) - .expect("resource not found in APIResourceList"); - let scope = if ar.namespaced { - Scope::Namespaced - } else { - Scope::Cluster - }; - let mut subresources = Vec::new(); - let subresource_name_prefix = format!("{}/", name); - for res in &list.resources { - if let Some(subresource_name) = res.name.strip_prefix(&subresource_name_prefix) { - let mut api_resource = ApiResource::from_apiresource(res, &list.group_version); - api_resource.plural = subresource_name.to_string(); - let extra = ApiResourceExtras::from_apiresourcelist(list, &res.name); - subresources.push((api_resource, extra)); - } - } - - ApiResourceExtras { - scope, - subresources, - operations: ar.verbs.clone(), - } - } - - /// Checks that given verb is supported on this resource. - pub fn supports_operation(&self, operation: &str) -> bool { - self.operations.iter().any(|op| op == operation) - } -} - -struct GroupVersionData { - version: String, - // list: APIResourceList, - resources: Vec<(ApiResource, ApiResourceExtras)>, -} - -impl GroupVersionData { - fn new(version: String, list: APIResourceList) -> Self { - // TODO: could be better than O(N^2). - let mut resources = Vec::new(); - for res in &list.resources { - // skip subresources - if res.name.contains('/') { - continue; - } - let api_res = ApiResource::from_apiresource(res, &list.group_version); - let extra = ApiResourceExtras::from_apiresourcelist(&list, &res.name); - resources.push((api_res, extra)); - } - GroupVersionData { - version, - resources, - //list: list.clone(), - // resources: filter_api_resource_list(list), - } - } -} - -/// Describes one API group. -pub struct ApiGroup { - name: String, - versions_and_resources: Vec, - preferred_version: Option, -} - -/// Cached APIs information. -/// -/// On creation `Discovery` queries Kubernetes API, -/// making list of all API resources, and provides a simple -/// interface on the top of that information. -/// -/// # Resource representation -/// Each resource is represented as a pair -/// `(ApiResource, ApiResourceExtras)`. Former can be used -/// to make API requests (together with the `DynamicObject` -/// or `Object`). Latter provides additional information such -/// as scope or supported verbs. -pub struct Discovery { - groups: HashMap, -} - -// TODO: this is pretty unoptimized -impl Discovery { - /// Discovers all APIs available in the cluster, - /// including CustomResourceDefinitions - // TODO: add more constructors - #[tracing::instrument(skip(client))] - pub async fn new(client: &Client) -> crate::Result { - let api_groups = client.list_api_groups().await?; - let mut groups = HashMap::new(); - for g in api_groups.groups { - tracing::debug!(name = g.name.as_str(), "Listing group versions"); - if g.versions.is_empty() { - tracing::warn!(name = g.name.as_str(), "Skipping group with empty versions list"); - continue; - } - let mut v = Vec::new(); - for vers in g.versions { - let resource_list = client.list_api_group_resources(&vers.group_version).await?; - - v.push(GroupVersionData::new(vers.version, resource_list)); - } - groups.insert(g.name.clone(), ApiGroup { - name: g.name, - versions_and_resources: v, - preferred_version: g.preferred_version.map(|v| v.version), - }); - } - - let coreapis = client.list_core_api_versions().await?; - let mut core_v = Vec::new(); - for core_ver in coreapis.versions { - let resource_list = client.list_core_api_resources(&core_ver).await?; - core_v.push(GroupVersionData::new(core_ver, resource_list)); - } - groups.insert(ApiGroup::CORE_GROUP.to_string(), ApiGroup { - name: ApiGroup::CORE_GROUP.to_string(), - versions_and_resources: core_v, - preferred_version: Some("v1".to_string()), - }); - - groups.values_mut().for_each(|group| group.sort_versions()); - - Ok(Discovery { groups }) - } - - /// Utility function that splits apiVersion into a group and version - /// that can be later used with this type. - pub fn parse_api_version(api_version: &str) -> Option<(&str, &str)> { - let mut iter = api_version.rsplitn(2, '/'); - let version = iter.next()?; - let group = iter.next().unwrap_or(ApiGroup::CORE_GROUP); - Some((group, version)) - } - - /// Returns iterator over all served groups - pub fn groups(&self) -> impl Iterator { - self.groups.iter().map(|(_, group)| group) - } - - /// Returns information about the group `g`, if it is served. - pub fn group(&self, g: &str) -> Option<&ApiGroup> { - self.groups.get(g) - } - - /// Checks if the group `g` is served. - pub fn has_group(&self, g: &str) -> bool { - self.group(g).is_some() - } - - /// Returns resource with given group, version and kind. - /// - /// This function returns `ApiResource` which can be used together - /// with `DynamicObject` and raw `ApiResourceExtras` value with additional information. - pub fn resolve_group_version_kind( - &self, - group: &str, - version: &str, - kind: &str, - ) -> Option<(ApiResource, ApiResourceExtras)> { - // TODO: could be better than O(N) - let group = self.group(group)?; - group - .resources_by_version(version) - .into_iter() - .find(|res| res.0.kind == kind) - } -} - -impl ApiGroup { - /// Core group name - pub const CORE_GROUP: &'static str = "core"; - - /// Returns the name of this group. - /// For core group (served at `/api`), returns "core" (also declared as - /// `Group::CORE`). - pub fn name(&self) -> &str { - &self.name - } - - /// Actually sets up order promised by `version` - fn sort_versions(&mut self) { - self.versions_and_resources - .sort_by_cached_key(|ver_data| Version::parse(ver_data.version.as_str())) - } - - /// Returns served versions (e.g. `["v1", "v2beta1"]`) of this group. - /// This list is always non-empty, and sorted in the following order: - /// - Stable versions (with the last being the first) - /// - Beta versions (with the last being the first) - /// - Alpha versions (with the last being the first) - /// - Other versions, alphabetically - // Order is documented here: - // https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#specify-multiple-versions - pub fn versions(&self) -> impl Iterator { - let versions = self.versions_and_resources.as_slice(); - versions.iter().map(|ver_data| ver_data.version.as_str()) - } - - /// Returns preferred version for working with given group. - pub fn preferred_version(&self) -> Option<&str> { - self.preferred_version.as_deref() - } - - /// Returns preferred version for working with given group. - /// If server does not recommend one, this function picks - /// "the most stable and the most recent" version. - pub fn preferred_version_or_guess(&self) -> &str { - match &self.preferred_version { - Some(v) => v, - None => self.versions().next().unwrap(), - } - } - - /// Returns resources available in version `ver` of this group. - /// If the group does not support this version, - /// returns empty vector. - pub fn resources_by_version(&self, ver: &str) -> Vec<(ApiResource, ApiResourceExtras)> { - let resources = self - .versions_and_resources - .iter() - .find(|ver_data| ver_data.version == ver) - .map(|ver_data| ver_data.resources.as_slice()) - .unwrap_or(&[]); - resources.to_vec() - } -} - -#[derive(PartialEq, Eq, Debug)] -enum Version { - Stable(u32), - Beta(u32, Option), - Alpha(u32, Option), - // CRDs and APIServices can use arbitrary strings as versions. - Nonconformant(String), -} - -#[derive(PartialEq, Eq, PartialOrd, Ord)] -enum VersionSortKey<'a> { - Stable(Reverse), - Beta(Reverse, Reverse>), - Alpha(Reverse, Reverse>), - Nonconformant(&'a str), -} - -impl Version { - fn to_sort_key(&self) -> VersionSortKey { - match self { - Version::Stable(v) => VersionSortKey::Stable(Reverse(*v)), - Version::Beta(v, beta) => VersionSortKey::Beta(Reverse(*v), Reverse(*beta)), - Version::Alpha(v, alpha) => VersionSortKey::Alpha(Reverse(*v), Reverse(*alpha)), - Version::Nonconformant(nc) => VersionSortKey::Nonconformant(nc), - } - } - - fn try_parse(v: &str) -> Option { - let v = v.strip_prefix('v')?; - let major_chars = v.chars().take_while(|ch| ch.is_ascii_digit()).count(); - let major = &v[..major_chars]; - let major: u32 = major.parse().ok()?; - let v = &v[major_chars..]; - if v.is_empty() { - return Some(Version::Stable(major)); - } - if let Some(suf) = v.strip_prefix("alpha") { - return if suf.is_empty() { - Some(Version::Alpha(major, None)) - } else { - Some(Version::Alpha(major, Some(suf.parse().ok()?))) - }; - } - if let Some(suf) = v.strip_prefix("beta") { - return if suf.is_empty() { - Some(Version::Beta(major, None)) - } else { - Some(Version::Beta(major, Some(suf.parse().ok()?))) - }; - } - None - } - - fn parse(v: &str) -> Version { - match Self::try_parse(v) { - Some(ver) => ver, - None => Version::Nonconformant(v.to_string()), - } - } -} - -impl Ord for Version { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.to_sort_key().cmp(&other.to_sort_key()) - } -} - -impl PartialOrd for Version { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -#[cfg(test)] -mod tests { - use super::Version; - fn check_parses_to(s: &str, v: Version) { - assert_eq!(Version::parse(s), v); - } - - #[test] - fn test_stable() { - check_parses_to("v1", Version::Stable(1)); - check_parses_to("v3", Version::Stable(3)); - check_parses_to("v10", Version::Stable(10)); - } - - #[test] - fn test_prerelease() { - check_parses_to("v1beta", Version::Beta(1, None)); - check_parses_to("v2alpha1", Version::Alpha(2, Some(1))); - check_parses_to("v10beta12", Version::Beta(10, Some(12))); - } - - fn check_not_parses(s: &str) { - check_parses_to(s, Version::Nonconformant(s.to_string())) - } - - #[test] - fn test_nonconformant() { - check_not_parses(""); - check_not_parses("foo"); - check_not_parses("v"); - check_not_parses("v-1"); - check_not_parses("valpha"); - check_not_parses("vbeta3"); - check_not_parses("vv1"); - check_not_parses("v1alpha1hi"); - check_not_parses("v1zeta3"); - } -} diff --git a/kube/src/client/mod.rs b/kube/src/client/mod.rs index fcb9cef01..b0a28d05c 100644 --- a/kube/src/client/mod.rs +++ b/kube/src/client/mod.rs @@ -2,10 +2,11 @@ //! //! The [`Client`] uses standard kube error handling. //! -//! This client can be used on its own or in conjuction with -//! the [`Api`][crate::api::Api] type for more structured -//! interaction with the kuberneres API. -pub mod discovery; +//! This client can be used on its own or in conjuction with the [`Api`][crate::api::Api] +//! type for more structured interaction with the kubernetes API. +//! +//! The [`Client`] can also be used with [`Discovery`](crate::Discovery) to dynamically +//! retrieve the resources served by the kubernetes API. use std::convert::{TryFrom, TryInto}; @@ -16,7 +17,7 @@ use http::{self, HeaderValue, Request, Response, StatusCode}; use hyper::Body; use hyper_timeout::TimeoutConnector; use k8s_openapi::apimachinery::pkg::apis::meta::v1 as k8s_meta_v1; -use kube_core::response::Status; +pub use kube_core::response::Status; use serde::de::DeserializeOwned; use serde_json::{self, Value}; #[cfg(feature = "ws")] @@ -296,7 +297,14 @@ impl Client { } })) } +} +/// Low level discovery methods using `k8s_openapi` types. +/// +/// Consider using the [`discovery`](crate::discovery) module for +/// easier-to-use variants of this functionality. +/// The following methods might be deprecated to avoid confusion between similarly named types within `discovery`. +impl Client { /// Returns apiserver version. pub async fn apiserver_version(&self) -> Result { self.request(Request::builder().uri("/version").body(vec![])?) diff --git a/kube/src/config/mod.rs b/kube/src/config/mod.rs index cd46a7d93..72a6d1047 100644 --- a/kube/src/config/mod.rs +++ b/kube/src/config/mod.rs @@ -12,8 +12,7 @@ mod utils; use crate::{error::ConfigError, Result}; use file_loader::ConfigLoader; pub use file_loader::KubeConfigOptions; -#[cfg(feature = "client")] -pub(crate) use utils::read_file_to_string; +#[cfg(feature = "client")] pub(crate) use utils::read_file_to_string; use http::header::HeaderMap; diff --git a/kube/src/discovery/apigroup.rs b/kube/src/discovery/apigroup.rs new file mode 100644 index 000000000..bb9b34abf --- /dev/null +++ b/kube/src/discovery/apigroup.rs @@ -0,0 +1,269 @@ +use super::{ + parse::{self, GroupVersionData}, + version::Version, +}; +use crate::{error::DiscoveryError, Client, Result}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{APIGroup, APIVersions}; +pub use kube_core::discovery::{verbs, ApiCapabilities, ApiResource, Scope}; +use kube_core::gvk::{GroupVersion, GroupVersionKind}; + + +/// Describes one API groups collected resources and capabilities. +/// +/// Each `ApiGroup` contains all data pinned to a each version. +/// In particular, one data set within the `ApiGroup` for `"apiregistration.k8s.io"` +/// is the subset pinned to `"v1"`; commonly referred to as `"apiregistration.k8s.io/v1"`. +/// +/// If you know the version of the discovered group, you can fetch it directly: +/// ```no_run +/// use kube::{Client, api::{Api, DynamicObject}, discovery, ResourceExt}; +/// #[tokio::main] +/// async fn main() -> Result<(), kube::Error> { +/// let client = Client::try_default().await?; +/// let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?; +/// for (apiresource, caps) in apigroup.versioned_resources("v1") { +/// println!("Found ApiResource {}", apiresource.kind); +/// } +/// Ok(()) +/// } +/// ``` +/// +/// But if you do not know this information, you can use [`ApiGroup::preferred_version_or_latest`]. +/// +/// Whichever way you choose the end result is something describing a resource and its abilities: +/// - `Vec<(ApiResource, `ApiCapabilities)>` :: for all resources in a versioned ApiGroup +/// - `(ApiResource, ApiCapabilities)` :: for a single kind under a versioned ApiGroud +/// +/// These two types: [`ApiResource`], and [`ApiCapabilities`] +/// should contain the information needed to construct an [`Api`](crate::Api) and start querying the kubernetes API. +/// You will likely need to use [`DynamicObject`] as the generic type for Api to do this, +/// as well as the [`ApiResource`] for the `DynamicType` for the [`Resource`] trait. +/// +/// ```no_run +/// use kube::{Client, api::{Api, DynamicObject}, discovery, ResourceExt}; +/// #[tokio::main] +/// async fn main() -> Result<(), kube::Error> { +/// let client = Client::try_default().await?; +/// let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?; +/// let (ar, caps) = apigroup.recommended_kind("APIService").unwrap(); +/// let api: Api = Api::all_with(client.clone(), &ar); +/// for service in api.list(&Default::default()).await? { +/// println!("Found APIService: {}", service.name()); +/// } +/// Ok(()) +/// } +/// ``` +/// [`ApiResource`]: crate::discovery::ApiResource +/// [`ApiCapabilities`]: crate::discovery::ApiCapabilities +/// [`DynamicObject`]: crate::api::DynamicObject +/// [`Resource`]: crate::Resource +/// [`ApiGroup::preferred_version_or_latest`]: crate::discovery::ApiGroup::preferred_version_or_latest +/// [`ApiGroup::versioned_resources`]: crate::discovery::ApiGroup::versioned_resources +/// [`ApiGroup::recommended_resources`]: crate::discovery::ApiGroup::recommended_resources +/// [`ApiGroup::recommended_kind`]: crate::discovery::ApiGroup::recommended_kind +pub struct ApiGroup { + /// Name of the group e.g. apiregistration.k8s.io + name: String, + /// List of resource information, capabilities at particular versions + data: Vec, + /// Preferred version if exported by the `APIGroup` + preferred: Option, +} + +/// Internal queriers to convert from an APIGroup (or APIVersions for core) to our ApiGroup +/// +/// These queriers ignore groups with empty versions. +/// This ensures that `ApiGroup::preferred_version_or_latest` always have an answer. +/// On construction, they also sort the internal vec of GroupVersionData according to `Version`. +impl ApiGroup { + pub(crate) async fn query_apis(client: &Client, g: APIGroup) -> Result { + tracing::debug!(name = g.name.as_str(), "Listing group versions"); + let key = g.name; + if g.versions.is_empty() { + return Err(DiscoveryError::EmptyApiGroup(key).into()); + } + let mut data = vec![]; + for vers in &g.versions { + let resources = client.list_api_group_resources(&vers.group_version).await?; + data.push(GroupVersionData::new(vers.version.clone(), resources)?); + } + let mut group = ApiGroup { + name: key, + data, + preferred: g.preferred_version.map(|v| v.version), + }; + group.sort_versions(); + Ok(group) + } + + pub(crate) async fn query_core(client: &Client, coreapis: APIVersions) -> Result { + let mut data = vec![]; + let key = ApiGroup::CORE_GROUP.to_string(); + if coreapis.versions.is_empty() { + return Err(DiscoveryError::EmptyApiGroup(key).into()); + } + for v in coreapis.versions { + let resources = client.list_core_api_resources(&v).await?; + data.push(GroupVersionData::new(v, resources)?); + } + let mut group = ApiGroup { + name: ApiGroup::CORE_GROUP.to_string(), + data, + preferred: Some("v1".to_string()), + }; + group.sort_versions(); + Ok(group) + } + + fn sort_versions(&mut self) { + self.data + .sort_by_cached_key(|gvd| Version::parse(gvd.version.as_str())) + } + + // shortcut method to give cheapest return for a single GVK + pub(crate) async fn query_gvk( + client: &Client, + gvk: &GroupVersionKind, + ) -> Result<(ApiResource, ApiCapabilities)> { + let apiver = gvk.api_version(); + let list = if gvk.group.is_empty() { + client.list_core_api_resources(&apiver).await? + } else { + client.list_api_group_resources(&apiver).await? + }; + for res in &list.resources { + if res.kind == gvk.kind && !res.name.contains('/') { + let ar = parse::parse_apiresource(res, &list.group_version)?; + let caps = parse::parse_apicapabilities(&list, &res.name)?; + return Ok((ar, caps)); + } + } + Err(DiscoveryError::MissingKind(format!("{:?}", gvk)).into()) + } + + // shortcut method to give cheapest return for a pinned group + pub(crate) async fn query_gv(client: &Client, gv: &GroupVersion) -> Result { + let apiver = gv.api_version(); + let list = if gv.group.is_empty() { + client.list_core_api_resources(&apiver).await? + } else { + client.list_api_group_resources(&apiver).await? + }; + let data = GroupVersionData::new(gv.version.clone(), list)?; + let group = ApiGroup { + name: gv.group.clone(), + data: vec![data], + preferred: Some(gv.version.clone()), // you preferred what you asked for + }; + Ok(group) + } +} + +/// Public ApiGroup interface +impl ApiGroup { + /// Core group name + pub const CORE_GROUP: &'static str = ""; + + /// Returns the name of this group. + pub fn name(&self) -> &str { + &self.name + } + + /// Returns served versions (e.g. `["v1", "v2beta1"]`) of this group. + /// + /// This list is always non-empty, and sorted in the following order: + /// - Stable versions (with the last being the first) + /// - Beta versions (with the last being the first) + /// - Alpha versions (with the last being the first) + /// - Other versions, alphabetically + /// + /// in accordance with [kubernetes version priority](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#version-priority). + pub fn versions(&self) -> impl Iterator { + self.data.as_slice().iter().map(|gvd| gvd.version.as_str()) + } + + /// Returns preferred version for working with given group. + pub fn preferred_version(&self) -> Option<&str> { + self.preferred.as_deref() + } + + /// Returns the preferred version or latest version for working with given group. + /// + /// If server does not recommend one, we pick the "most stable and most recent" version + /// in accordance with [kubernetes version priority](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#version-priority). + pub fn preferred_version_or_latest(&self) -> &str { + // NB: self.versions is non-empty by construction in ApiGroup + self.preferred + .as_deref() + .unwrap_or_else(|| self.versions().next().unwrap()) + } + + /// Returns the resources in the group at an arbitrary version string. + /// + /// If the group does not support this version, the returned vector is empty. + /// + /// If you are looking for the api recommended list of resources, or just on particular kind + /// consider [`ApiGroup::recommended_resources`] or [`ApiGroup::recommended_kind`] instead. + pub fn versioned_resources(&self, ver: &str) -> Vec<(ApiResource, ApiCapabilities)> { + self.data + .iter() + .find(|gvd| gvd.version == ver) + .map(|gvd| gvd.resources.clone()) + .unwrap_or_default() + } + + /// Returns the recommended (preferred or latest) versioned resources in the group + /// + /// ```no_run + /// use kube::{Client, api::{Api, DynamicObject}, discovery::{self, verbs}, ResourceExt}; + /// #[tokio::main] + /// async fn main() -> Result<(), kube::Error> { + /// let client = Client::try_default().await?; + /// let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?; + /// for (ar, caps) in apigroup.recommended_resources() { + /// if !caps.supports_operation(verbs::LIST) { + /// continue; + /// } + /// let api: Api = Api::all_with(client.clone(), &ar); + /// for inst in api.list(&Default::default()).await? { + /// println!("Found {}: {}", ar.kind, inst.name()); + /// } + /// } + /// Ok(()) + /// } + /// ``` + /// + /// This is equivalent to taking the [`ApiGroup::versioned_resources`] at the [`ApiGroup::preferred_version_or_latest`]. + pub fn recommended_resources(&self) -> Vec<(ApiResource, ApiCapabilities)> { + let ver = self.preferred_version_or_latest(); + self.versioned_resources(ver) + } + + /// Returns the recommended version of the `kind` in the recommended resources (if found) + /// + /// ```no_run + /// use kube::{Client, api::{Api, DynamicObject}, discovery, ResourceExt}; + /// #[tokio::main] + /// async fn main() -> Result<(), kube::Error> { + /// let client = Client::try_default().await?; + /// let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?; + /// let (ar, caps) = apigroup.recommended_kind("APIService").unwrap(); + /// let api: Api = Api::all_with(client.clone(), &ar); + /// for service in api.list(&Default::default()).await? { + /// println!("Found APIService: {}", service.name()); + /// } + /// Ok(()) + /// } + /// ``` + /// + /// This is equivalent to filtering the [`ApiGroup::versioned_resources`] at [`ApiGroup::preferred_version_or_latest`] against a chosen `kind`. + pub fn recommended_kind(&self, kind: &str) -> Option<(ApiResource, ApiCapabilities)> { + let ver = self.preferred_version_or_latest(); + for (ar, caps) in self.versioned_resources(ver) { + if ar.kind == kind { + return Some((ar, caps)); + } + } + None + } +} diff --git a/kube/src/discovery/mod.rs b/kube/src/discovery/mod.rs new file mode 100644 index 000000000..21e204ae5 --- /dev/null +++ b/kube/src/discovery/mod.rs @@ -0,0 +1,159 @@ +//! High-level utilities for runtime API discovery. + +use crate::{Client, Result}; +pub use kube_core::discovery::{verbs, ApiCapabilities, ApiResource, Scope}; +use kube_core::gvk::GroupVersionKind; +use std::collections::HashMap; +mod apigroup; +pub mod oneshot; +pub use apigroup::ApiGroup; +mod parse; +// an implementation of mentioned kubernetes version priority +mod version; + +// re-export one-shots +pub use oneshot::{group, pinned_group, pinned_kind}; + +/// How the Discovery client decides what api groups to scan +enum DiscoveryMode { + /// Only allow explicitly listed apigroups + Allow(Vec), + /// Allow all apigroups except the ones listed + Block(Vec), +} + +impl DiscoveryMode { + #[allow(clippy::ptr_arg)] // hashmap complains on &str here + fn is_queryable(&self, group: &String) -> bool { + match &self { + Self::Allow(allowed) => allowed.contains(group), + Self::Block(blocked) => !blocked.contains(group), + } + } +} + +/// A caching client for running API discovery against the Kubernetes API. +/// +/// This simplifies the required querying and type matching, and stores the responses +/// for each discovered api group and exposes helpers to access them. +/// +/// The discovery process varies in complexity depending on: +/// - how much you know about the kind(s) and group(s) you are interested in +/// - how many groups you are interested in +/// +/// Discovery can be performed on: +/// - all api groups (default) +/// - a subset of api groups (by setting Discovery::filter) +/// +/// To make use of discovered apis, extract one or more [`ApiGroup`]s from it, +/// or resolve a precise one using [`Discovery::resolve_gvk`](crate::discovery::Discovery::resolve_gvk). +/// +/// If caching of results is __not required__, then a simpler [`oneshot`](crate::discovery::oneshot) discovery system can be used. +/// +/// [`ApiGroup`]: crate::discovery::ApiGroup +pub struct Discovery { + client: Client, + groups: HashMap, + mode: DiscoveryMode, +} + +/// Caching discovery interface +/// +/// Builds an internal map of its cache +impl Discovery { + /// Construct a caching api discovery client + pub fn new(client: Client) -> Self { + let groups = HashMap::new(); + let mode = DiscoveryMode::Block(vec![]); + Self { client, groups, mode } + } + + /// Configure the discovery client to only look for the listed apigroups + pub fn filter(mut self, allow: &[&str]) -> Self { + self.mode = DiscoveryMode::Allow(allow.iter().map(ToString::to_string).collect()); + self + } + + /// Configure the discovery client to look for all apigroups except the listed ones + pub fn exclude(mut self, deny: &[&str]) -> Self { + self.mode = DiscoveryMode::Block(deny.iter().map(ToString::to_string).collect()); + self + } + + /// Runs or re-runs the configured discovery algorithm and updates/populates the cache + /// + /// The cache is empty cleared when this is started. By default, every api group found is checked, + /// causing `N+2` queries to the api server (where `N` is number of api groups). + /// + /// ```no_run + /// use kube::{Client, api::{Api, DynamicObject}, discovery::{Discovery, verbs, Scope}, ResourceExt}; + /// #[tokio::main] + /// async fn main() -> Result<(), kube::Error> { + /// let client = Client::try_default().await?; + /// let discovery = Discovery::new(client.clone()).run().await?; + /// for group in discovery.groups() { + /// for (ar, caps) in group.recommended_resources() { + /// if !caps.supports_operation(verbs::LIST) { + /// continue; + /// } + /// let api: Api = Api::all_with(client.clone(), &ar); + /// // can now api.list() to emulate kubectl get all --all + /// for obj in api.list(&Default::default()).await? { + /// println!("{} {}: {}", ar.api_version, ar.kind, obj.name()); + /// } + /// } + /// } + /// Ok(()) + /// } + /// ``` + /// See a bigger example in [examples/dynamic.api](https://github.com/clux/kube-rs/blob/master/examples/dynamic_api.rs) + pub async fn run(mut self) -> Result { + self.groups.clear(); + let api_groups = self.client.list_api_groups().await?; + // query regular groups + crds under /apis + for g in api_groups.groups { + let key = g.name.clone(); + if self.mode.is_queryable(&key) { + let apigroup = ApiGroup::query_apis(&self.client, g).await?; + self.groups.insert(key, apigroup); + } + } + // query core versions under /api + let corekey = ApiGroup::CORE_GROUP.to_string(); + if self.mode.is_queryable(&corekey) { + let coreapis = self.client.list_core_api_versions().await?; + let apigroup = ApiGroup::query_core(&self.client, coreapis).await?; + self.groups.insert(corekey, apigroup); + } + Ok(self) + } +} + +/// Interface to the Discovery cache +impl Discovery { + /// Returns iterator over all served groups + pub fn groups(&self) -> impl Iterator { + self.groups.values() + } + + /// Returns the [`ApiGroup`] for a given group if served + pub fn get(&self, group: &str) -> Option<&ApiGroup> { + self.groups.get(group) + } + + /// Check if a group is served by the apiserver + pub fn has_group(&self, group: &str) -> bool { + self.groups.contains_key(group) + } + + /// Finds an [`ApiResource`] and its [`ApiCapabilities`] after discovery by matching a GVK + /// + /// This is for quick extraction after having done a complete discovery. + /// If you are only interested in a single kind, consider [`oneshot::pinned_kind`](crate::discovery::pinned_kind). + pub fn resolve_gvk(&self, gvk: &GroupVersionKind) -> Option<(ApiResource, ApiCapabilities)> { + self.get(&gvk.group)? + .versioned_resources(&gvk.version) + .into_iter() + .find(|res| res.0.kind == gvk.kind) + } +} diff --git a/kube/src/discovery/oneshot.rs b/kube/src/discovery/oneshot.rs new file mode 100644 index 000000000..11e472cd2 --- /dev/null +++ b/kube/src/discovery/oneshot.rs @@ -0,0 +1,104 @@ +//! single use discovery utils +//! +//! These helpers provides a simpler discovery interface, but do not offer any built-in caching. +//! +//! This can provide specific information for 3 cases: +//! - single kind in a particular group at a pinned version via [`oneshot::pinned_kind`] +//! - all kinds in a group at pinned version: "apiregistration.k8s.io/v1" via [`oneshot::pinned_group`] +//! - all kinds/version combinations in a group: "apiregistration.k8s.io" via [`oneshot::group`] +//! +//! [`oneshot::group`]: crate::discovery::group +//! [`oneshot::pinned_group`]: crate::discovery::pinned_group +//! [`oneshot::pinned_kind`]: crate::discovery::pinned_kind + +use super::ApiGroup; +use crate::{error::DiscoveryError, Client, Result}; +use kube_core::{ + discovery::{ApiCapabilities, ApiResource}, + gvk::{GroupVersion, GroupVersionKind}, +}; + +/// Discovers all APIs available under a certain group at all versions +/// +/// This is recommended if you work with one group, but do not want to pin the version +/// of the apigroup. You can instead work with a recommended version (preferred or latest). +/// +/// ```no_run +/// use kube::{Client, api::{Api, DynamicObject}, discovery, ResourceExt}; +/// #[tokio::main] +/// async fn main() -> Result<(), kube::Error> { +/// let client = Client::try_default().await?; +/// let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?; +/// let (ar, caps) = apigroup.recommended_kind("APIService").unwrap(); +/// let api: Api = Api::all_with(client.clone(), &ar); +/// for service in api.list(&Default::default()).await? { +/// println!("Found APIService: {}", service.name()); +/// } +/// Ok(()) +/// } +/// ``` +pub async fn group(client: &Client, apigroup: &str) -> Result { + if apigroup == ApiGroup::CORE_GROUP { + let coreapis = client.list_core_api_versions().await?; + return ApiGroup::query_core(&client, coreapis).await; + } else { + let api_groups = client.list_api_groups().await?; + for g in api_groups.groups { + if g.name != apigroup { + continue; + } + return ApiGroup::query_apis(&client, g).await; + } + } + Err(DiscoveryError::MissingApiGroup(apigroup.to_string()).into()) +} + +/// Discovers all APIs available under a certain group at a pinned version +/// +/// This is a cheaper variant of [`oneshot::group`](crate::discovery::oneshot::group) when you know what version you want. +/// +/// ```no_run +/// use kube::{Client, api::{Api, DynamicObject}, discovery, ResourceExt}; +/// #[tokio::main] +/// async fn main() -> Result<(), kube::Error> { +/// let client = Client::try_default().await?; +/// let gv = "apiregistration.k8s.io/v1".parse()?; +/// let apigroup = discovery::pinned_group(&client, &gv).await?; +/// let (ar, caps) = apigroup.recommended_kind("APIService").unwrap(); +/// let api: Api = Api::all_with(client.clone(), &ar); +/// for service in api.list(&Default::default()).await? { +/// println!("Found APIService: {}", service.name()); +/// } +/// Ok(()) +/// } +/// ``` +/// +/// While this example only uses a single kind, this type of discovery works best when you need more +/// than a single `kind`. +/// If you only need a single `kind`, [`oneshot::pinned_kind`](crate::discovery::pinned_kind) is the best solution. +pub async fn pinned_group(client: &Client, gv: &GroupVersion) -> Result { + ApiGroup::query_gv(&client, gv).await +} + +/// Single discovery for a single GVK +/// +/// This is an optimized function that avoids the unnecessary listing of api groups. +/// It merely requests the api group resources for the specified apigroup, and then resolves the kind. +/// +/// ```no_run +/// use kube::{Client, api::{Api, DynamicObject, GroupVersionKind}, discovery, ResourceExt}; +/// #[tokio::main] +/// async fn main() -> Result<(), kube::Error> { +/// let client = Client::try_default().await?; +/// let gvk = GroupVersionKind::gvk("apiregistration.k8s.io", "v1", "APIService"); +/// let (ar, caps) = discovery::pinned_kind(&client, &gvk).await?; +/// let api: Api = Api::all_with(client.clone(), &ar); +/// for service in api.list(&Default::default()).await? { +/// println!("Found APIService: {}", service.name()); +/// } +/// Ok(()) +/// } +/// ``` +pub async fn pinned_kind(client: &Client, gvk: &GroupVersionKind) -> Result<(ApiResource, ApiCapabilities)> { + ApiGroup::query_gvk(client, &gvk).await +} diff --git a/kube/src/discovery/parse.rs b/kube/src/discovery/parse.rs new file mode 100644 index 000000000..afd481377 --- /dev/null +++ b/kube/src/discovery/parse.rs @@ -0,0 +1,80 @@ +//! Abstractions on top of k8s_openapi::apimachinery::pkg::apis::meta::v1 +use crate::{error::DiscoveryError, Result}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{APIResource, APIResourceList}; +use kube_core::{ + discovery::{ApiCapabilities, ApiResource, Scope}, + gvk::GroupVersion, +}; + +/// Creates an `ApiResource` from a `meta::v1::APIResource` instance + its groupversion. +/// +/// Returns a `DiscoveryError` if the passed group_version cannot be parsed +pub(crate) fn parse_apiresource(ar: &APIResource, group_version: &str) -> Result { + let gv: GroupVersion = group_version.parse()?; + // NB: not safe to use this with subresources (they don't have api_versions) + Ok(ApiResource { + group: ar.group.clone().unwrap_or_else(|| gv.group.clone()), + version: ar.version.clone().unwrap_or_else(|| gv.version.clone()), + api_version: gv.api_version(), + kind: ar.kind.to_string(), + plural: ar.name.clone(), + }) +} + +/// Creates `ApiCapabilities` from a `meta::v1::APIResourceList` instance + a name from the list. +/// +/// Returns a `DiscoveryError` if the list does not contain resource with passed `name`. +pub(crate) fn parse_apicapabilities(list: &APIResourceList, name: &str) -> Result { + let ar = list + .resources + .iter() + .find(|r| r.name == name) + .ok_or_else(|| DiscoveryError::MissingResource(name.into()))?; + let scope = if ar.namespaced { + Scope::Namespaced + } else { + Scope::Cluster + }; + + let subresource_name_prefix = format!("{}/", name); + let mut subresources = vec![]; + for res in &list.resources { + if let Some(subresource_name) = res.name.strip_prefix(&subresource_name_prefix) { + let mut api_resource = parse_apiresource(res, &list.group_version)?; + api_resource.plural = subresource_name.to_string(); + let caps = parse_apicapabilities(list, &res.name)?; // NB: recursion + subresources.push((api_resource, caps)); + } + } + Ok(ApiCapabilities { + scope, + subresources, + operations: ar.verbs.clone(), + }) +} + +/// Internal resource information and capabilities for a particular ApiGroup at a particular version +pub(crate) struct GroupVersionData { + /// Pinned api version + pub(crate) version: String, + /// Pair of dynamic resource info along with what it supports. + pub(crate) resources: Vec<(ApiResource, ApiCapabilities)>, +} + +impl GroupVersionData { + /// Given an APIResourceList, extract all information for a given version + pub(crate) fn new(version: String, list: APIResourceList) -> Result { + let mut resources = vec![]; + for res in &list.resources { + // skip subresources + if res.name.contains('/') { + continue; + } + // NB: these two should be infallible from discovery when k8s api is well-behaved, but.. + let ar = parse_apiresource(res, &list.group_version)?; + let caps = parse_apicapabilities(&list, &res.name)?; + resources.push((ar, caps)); + } + Ok(GroupVersionData { version, resources }) + } +} diff --git a/kube/src/discovery/version.rs b/kube/src/discovery/version.rs new file mode 100644 index 000000000..06aee5731 --- /dev/null +++ b/kube/src/discovery/version.rs @@ -0,0 +1,150 @@ +//! Version definitions to allow sorting (not exported) +//! +//! Follows [kubernetes version priority](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#version-priority) +//! but only in sort-order at the moment. +//! +//! Changes to Ord is needed if this is ever exported outside the crate. + +use std::cmp::Reverse; + +#[derive(PartialEq, Eq, Debug)] +pub(crate) enum Version { + Stable(u32), + Beta(u32, Option), + Alpha(u32, Option), + // CRDs and APIServices can use arbitrary strings as versions. + Nonconformant(String), +} + +impl Version { + fn try_parse(v: &str) -> Option { + let v = v.strip_prefix('v')?; + let major_chars = v.chars().take_while(|ch| ch.is_ascii_digit()).count(); + let major = &v[..major_chars]; + let major: u32 = major.parse().ok()?; + let v = &v[major_chars..]; + if v.is_empty() { + return Some(Version::Stable(major)); + } + if let Some(suf) = v.strip_prefix("alpha") { + return if suf.is_empty() { + Some(Version::Alpha(major, None)) + } else { + Some(Version::Alpha(major, Some(suf.parse().ok()?))) + }; + } + if let Some(suf) = v.strip_prefix("beta") { + return if suf.is_empty() { + Some(Version::Beta(major, None)) + } else { + Some(Version::Beta(major, Some(suf.parse().ok()?))) + }; + } + None + } + + pub(crate) fn parse(v: &str) -> Version { + match Self::try_parse(v) { + Some(ver) => ver, + None => Version::Nonconformant(v.to_string()), + } + } +} +// A key used to allow sorting Versions +#[derive(PartialEq, Eq, PartialOrd, Ord)] +enum VersionSortKey<'a> { + Stable(Reverse), + Beta(Reverse, Reverse>), + Alpha(Reverse, Reverse>), + Nonconformant(&'a str), +} +impl Version { + fn to_sort_key(&self) -> VersionSortKey { + match self { + Version::Stable(v) => VersionSortKey::Stable(Reverse(*v)), + Version::Beta(v, beta) => VersionSortKey::Beta(Reverse(*v), Reverse(*beta)), + Version::Alpha(v, alpha) => VersionSortKey::Alpha(Reverse(*v), Reverse(*alpha)), + Version::Nonconformant(nc) => VersionSortKey::Nonconformant(nc), + } + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.to_sort_key().cmp(&other.to_sort_key()) + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(&other)) + } +} + +#[cfg(test)] +mod tests { + use super::Version; + + #[test] + fn test_stable() { + assert_eq!(Version::parse("v1"), Version::Stable(1)); + assert_eq!(Version::parse("v3"), Version::Stable(3)); + assert_eq!(Version::parse("v10"), Version::Stable(10)); + } + + #[test] + fn test_prerelease() { + assert_eq!(Version::parse("v1beta"), Version::Beta(1, None)); + assert_eq!(Version::parse("v2alpha1"), Version::Alpha(2, Some(1))); + assert_eq!(Version::parse("v10beta12"), Version::Beta(10, Some(12))); + } + + fn check_not_parses(s: &str) { + assert_eq!(Version::parse(s), Version::Nonconformant(s.to_string())) + } + + #[test] + fn test_nonconformant() { + check_not_parses(""); + check_not_parses("foo"); + check_not_parses("v"); + check_not_parses("v-1"); + check_not_parses("valpha"); + check_not_parses("vbeta3"); + check_not_parses("vv1"); + check_not_parses("v1alpha1hi"); + check_not_parses("v1zeta3"); + } + + #[test] + fn test_version_ord() { + // NB: semantically this is exact opposite of what makes sense, but we never export Version. + // We can reverse the Ord, but would have to remember to .reverse() again after sorts. + assert!(Version::Stable(2) < Version::Stable(1)); + assert!(Version::Stable(2) < Version::Stable(1)); + assert!(Version::Stable(1) < Version::Beta(1, None)); + assert!(Version::Stable(1) < Version::Beta(2, None)); + assert!(Version::Stable(2) < Version::Alpha(1, Some(2))); + assert!(Version::Stable(1) < Version::Alpha(2, Some(2))); + assert!(Version::Beta(1, None) < Version::Nonconformant("hi".into())); + + // The sorting results is what we export, and this works by default because Ord is reversed: + let mut vers = vec![ + Version::Beta(2, Some(2)), + Version::Stable(1), + Version::Nonconformant("hi".into()), + Version::Alpha(1, Some(2)), + Version::Stable(2), + Version::Beta(2, Some(3)), + ]; + vers.sort(); + assert_eq!(vers, vec![ + Version::Stable(2), + Version::Stable(1), + Version::Beta(2, Some(3)), + Version::Beta(2, Some(2)), + Version::Alpha(1, Some(2)), + Version::Nonconformant("hi".into()), + ]); + } +} diff --git a/kube/src/error.rs b/kube/src/error.rs index 8a43ab767..f756f0483 100644 --- a/kube/src/error.rs +++ b/kube/src/error.rs @@ -75,6 +75,10 @@ pub enum Error { #[error("Error loading kubeconfig: {0}")] Kubeconfig(#[from] ConfigError), + /// Discovery errors + #[error("Error from discovery: {0}")] + Discovery(#[from] DiscoveryError), + /// An error with configuring SSL occured #[error("SslError: {0}")] SslError(String), @@ -260,12 +264,32 @@ impl From for Error { } } +#[derive(Error, Debug)] +// Redundant with the error messages and machine names +#[allow(missing_docs)] +/// Possible errors when using API discovery +pub enum DiscoveryError { + #[error("Invalid GroupVersion: {0}")] + InvalidGroupVersion(String), + #[error("Missing Kind: {0}")] + MissingKind(String), + #[error("Missing Api Group: {0}")] + MissingApiGroup(String), + #[error("Missing MissingResource: {0}")] + MissingResource(String), + #[error("Empty Api Group: {0}")] + EmptyApiGroup(String), +} + impl From for Error { fn from(error: kube_core::Error) -> Self { match error { kube_core::Error::RequestValidation(s) => Error::RequestValidation(s), kube_core::Error::SerdeError(e) => Error::SerdeError(e), kube_core::Error::HttpError(e) => Error::HttpError(e), + kube_core::Error::InvalidGroupVersion(s) => { + Error::Discovery(DiscoveryError::InvalidGroupVersion(s)) + } } } } diff --git a/kube/src/lib.rs b/kube/src/lib.rs index d37979a0e..760ea275e 100644 --- a/kube/src/lib.rs +++ b/kube/src/lib.rs @@ -123,6 +123,7 @@ macro_rules! cfg_error { cfg_client! { pub mod api; + pub mod discovery; pub mod client; pub(crate) mod service; @@ -130,6 +131,8 @@ cfg_client! { pub use api::Api; #[doc(inline)] pub use client::Client; + #[doc(inline)] + pub use discovery::Discovery; } cfg_config! {