Skip to main content

kube_client/discovery/
mod.rs

1//! High-level utilities for runtime API discovery.
2
3use crate::{Client, Result};
4pub use kube_core::discovery::{ApiCapabilities, ApiResource, Scope, verbs};
5use kube_core::gvk::GroupVersionKind;
6use kube_core::{DynamicObject, TypeMeta};
7use std::collections::HashMap;
8mod apigroup;
9pub mod oneshot;
10pub use apigroup::ApiGroup;
11mod parse;
12
13// re-export one-shots
14pub use oneshot::{group, pinned_group, pinned_kind};
15
16/// How the Discovery client decides what api groups to scan
17enum DiscoveryMode {
18    /// Only allow explicitly listed apigroups
19    Allow(Vec<String>),
20    /// Allow all apigroups except the ones listed
21    Block(Vec<String>),
22}
23
24impl DiscoveryMode {
25    fn is_queryable(&self, group: &String) -> bool {
26        match &self {
27            Self::Allow(allowed) => allowed.contains(group),
28            Self::Block(blocked) => !blocked.contains(group),
29        }
30    }
31}
32
33/// A caching client for running API discovery against the Kubernetes API.
34///
35/// This simplifies the required querying and type matching, and stores the responses
36/// for each discovered api group and exposes helpers to access them.
37///
38/// The discovery process varies in complexity depending on:
39/// - how much you know about the kind(s) and group(s) you are interested in
40/// - how many groups you are interested in
41///
42/// Discovery can be performed on:
43/// - all api groups (default)
44/// - a subset of api groups (by setting Discovery::filter)
45///
46/// To make use of discovered apis, extract one or more [`ApiGroup`]s from it,
47/// or resolve a precise one using [`Discovery::resolve_gvk`](crate::discovery::Discovery::resolve_gvk).
48///
49/// If caching of results is __not required__, then a simpler [`oneshot`](crate::discovery::oneshot) discovery system can be used.
50///
51/// [`ApiGroup`]: crate::discovery::ApiGroup
52#[cfg_attr(docsrs, doc(cfg(feature = "client")))]
53pub struct Discovery {
54    client: Client,
55    groups: HashMap<String, ApiGroup>,
56    mode: DiscoveryMode,
57}
58
59/// Caching discovery interface
60///
61/// Builds an internal map of its cache
62impl Discovery {
63    /// Construct a caching api discovery client
64    #[must_use]
65    pub fn new(client: Client) -> Self {
66        let groups = HashMap::new();
67        let mode = DiscoveryMode::Block(vec![]);
68        Self { client, groups, mode }
69    }
70
71    /// Configure the discovery client to only look for the listed apigroups
72    #[must_use]
73    pub fn filter(mut self, allow: &[&str]) -> Self {
74        self.mode = DiscoveryMode::Allow(allow.iter().map(ToString::to_string).collect());
75        self
76    }
77
78    /// Configure the discovery client to look for all apigroups except the listed ones
79    #[must_use]
80    pub fn exclude(mut self, deny: &[&str]) -> Self {
81        self.mode = DiscoveryMode::Block(deny.iter().map(ToString::to_string).collect());
82        self
83    }
84
85    /// Runs or re-runs the configured discovery algorithm and updates/populates the cache
86    ///
87    /// The cache is empty cleared when this is started. By default, every api group found is checked,
88    /// causing `N+2` queries to the api server (where `N` is number of api groups).
89    ///
90    /// **Note**: Consider using [`Discovery::run_aggregated`] instead, which only requires
91    /// 2 API calls regardless of the number of groups (requires Kubernetes 1.26+).
92    ///
93    /// ```no_run
94    /// use kube::{Client, api::{Api, DynamicObject}, discovery::{Discovery, verbs, Scope}, ResourceExt};
95    /// #[tokio::main]
96    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
97    ///     let client = Client::try_default().await?;
98    ///     let discovery = Discovery::new(client.clone()).run().await?;
99    ///     for group in discovery.groups() {
100    ///         for (ar, caps) in group.recommended_resources() {
101    ///             if !caps.supports_operation(verbs::LIST) {
102    ///                 continue;
103    ///             }
104    ///             let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
105    ///             // can now api.list() to emulate kubectl get all --all
106    ///             for obj in api.list(&Default::default()).await? {
107    ///                 println!("{} {}: {}", ar.api_version, ar.kind, obj.name_any());
108    ///             }
109    ///         }
110    ///     }
111    ///     Ok(())
112    /// }
113    /// ```
114    /// See a bigger example in [examples/dynamic.api](https://github.com/kube-rs/kube/blob/main/examples/dynamic_api.rs)
115    pub async fn run(mut self) -> Result<Self> {
116        self.groups.clear();
117        let api_groups = self.client.list_api_groups().await?;
118        // query regular groups + crds under /apis
119        for g in api_groups.groups {
120            let key = g.name.clone();
121            if self.mode.is_queryable(&key) {
122                let apigroup = ApiGroup::query_apis(&self.client, g).await?;
123                self.groups.insert(key, apigroup);
124            }
125        }
126        // query core versions under /api
127        let corekey = ApiGroup::CORE_GROUP.to_string();
128        if self.mode.is_queryable(&corekey) {
129            let coreapis = self.client.list_core_api_versions().await?;
130            let apigroup = ApiGroup::query_core(&self.client, coreapis).await?;
131            self.groups.insert(corekey, apigroup);
132        }
133        Ok(self)
134    }
135
136    /// Runs discovery using the Aggregated Discovery API
137    ///
138    /// This method uses the Aggregated Discovery API (available since Kubernetes 1.26, stable in 1.30)
139    /// to fetch all API resources in just 2 requests instead of N+2 requests.
140    ///
141    /// The Aggregated Discovery API provides all resource information in the response to `/api` and `/apis`
142    /// when the appropriate Accept header is set, eliminating the need to query each group individually.
143    ///
144    /// ```no_run
145    /// use kube::{Client, api::{Api, DynamicObject}, discovery::{Discovery, verbs, Scope}, ResourceExt};
146    /// #[tokio::main]
147    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
148    ///     let client = Client::try_default().await?;
149    ///     // Uses only 2 API calls instead of N+2
150    ///     let discovery = Discovery::new(client.clone()).run_aggregated().await?;
151    ///     for group in discovery.groups() {
152    ///         for (ar, caps) in group.recommended_resources() {
153    ///             if !caps.supports_operation(verbs::LIST) {
154    ///                 continue;
155    ///             }
156    ///             let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
157    ///             for obj in api.list(&Default::default()).await? {
158    ///                 println!("{} {}: {}", ar.api_version, ar.kind, obj.name_any());
159    ///             }
160    ///         }
161    ///     }
162    ///     Ok(())
163    /// }
164    /// ```
165    ///
166    /// # Requirements
167    /// - Kubernetes 1.26+ (beta) or 1.30+ (stable)
168    ///
169    /// # Note
170    /// If the server does not support Aggregated Discovery, this will return an error.
171    /// Consider falling back to [`Discovery::run`] in that case.
172    pub async fn run_aggregated(mut self) -> Result<Self> {
173        self.groups.clear();
174
175        // Query /apis for all non-core groups (single request)
176        let apis_discovery = self.client.list_api_groups_aggregated().await?;
177        for ag in apis_discovery.items {
178            let key = ag
179                .metadata
180                .as_ref()
181                .and_then(|m| m.name.clone())
182                .unwrap_or_default();
183            if self.mode.is_queryable(&key) {
184                let apigroup = ApiGroup::from_v2(ag)?;
185                self.groups.insert(key, apigroup);
186            }
187        }
188
189        // Query /api for core group (single request)
190        let corekey = ApiGroup::CORE_GROUP.to_string();
191        if self.mode.is_queryable(&corekey) {
192            let core_discovery = self.client.list_core_api_versions_aggregated().await?;
193            // Core group is the first (and usually only) item
194            if let Some(core_ag) = core_discovery.items.into_iter().next() {
195                let apigroup = ApiGroup::from_v2(core_ag)?;
196                self.groups.insert(corekey, apigroup);
197            }
198        }
199
200        Ok(self)
201    }
202}
203
204/// Interface to the Discovery cache
205impl Discovery {
206    /// Returns iterator over all served groups
207    pub fn groups(&self) -> impl Iterator<Item = &ApiGroup> {
208        self.groups.values()
209    }
210
211    /// Returns a sorted vector of all served groups
212    ///
213    /// This vector is in kubectl's normal alphabetical group order
214    pub fn groups_alphabetical(&self) -> Vec<&ApiGroup> {
215        let mut values: Vec<_> = self.groups().collect();
216        // collect to maintain kubectl order of groups
217        values.sort_by_key(|g| g.name());
218        values
219    }
220
221    /// Returns the [`ApiGroup`] for a given group if served
222    pub fn get(&self, group: &str) -> Option<&ApiGroup> {
223        self.groups.get(group)
224    }
225
226    /// Check if a group is served by the apiserver
227    pub fn has_group(&self, group: &str) -> bool {
228        self.groups.contains_key(group)
229    }
230
231    /// Finds an [`ApiResource`] and its [`ApiCapabilities`] after discovery by matching a GVK
232    ///
233    /// This is for quick extraction after having done a complete discovery.
234    /// If you are only interested in a single kind, consider [`oneshot::pinned_kind`](crate::discovery::pinned_kind).
235    pub fn resolve_gvk(&self, gvk: &GroupVersionKind) -> Option<(ApiResource, ApiCapabilities)> {
236        self.get(&gvk.group)?
237            .versioned_resources(&gvk.version)
238            .into_iter()
239            .find(|res| res.0.kind == gvk.kind)
240    }
241
242    /// Finds an [`ApiResource`] and its [`ApiCapabilities`] from a [`TypeMeta`] after discovery
243    ///
244    /// A convenience wrapper around [`Discovery::resolve_gvk`] for when you have a [`TypeMeta`]
245    /// (e.g. taken from a [`DynamicObject`]) rather than a [`GroupVersionKind`]. Returns `None` if
246    /// the `apiVersion` cannot be parsed or the kind was not discovered.
247    pub fn resolve_typemeta(&self, tm: &TypeMeta) -> Option<(ApiResource, ApiCapabilities)> {
248        let gvk = GroupVersionKind::try_from(tm).ok()?;
249        self.resolve_gvk(&gvk)
250    }
251
252    /// Finds an [`ApiResource`] and its [`ApiCapabilities`] for a [`DynamicObject`] after discovery
253    ///
254    /// This makes it easy to build an [`Api`](crate::Api) for an object obtained dynamically
255    /// (e.g. from an admission request or another watch) without manually assembling a
256    /// [`GroupVersionKind`]:
257    ///
258    /// ```no_run
259    /// use kube::{Client, api::{Api, DynamicObject}, discovery::Discovery};
260    /// #[tokio::main]
261    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
262    ///     let client = Client::try_default().await?;
263    ///     let object: DynamicObject = todo!("an object obtained at runtime");
264    ///     let discovery = Discovery::new(client.clone()).run().await?;
265    ///     if let Some((ar, _caps)) = discovery.resolve_object(&object) {
266    ///         let api: Api<DynamicObject> = match object.metadata.namespace.as_deref() {
267    ///             Some(ns) => Api::namespaced_with(client.clone(), ns, &ar),
268    ///             None => Api::all_with(client.clone(), &ar),
269    ///         };
270    ///         // now `api` can be used to interact with the object
271    ///         let _ = api;
272    ///     }
273    ///     Ok(())
274    /// }
275    /// ```
276    ///
277    /// Returns `None` if the object has no [`TypeMeta`], its `apiVersion` cannot be parsed, or the
278    /// kind was not discovered.
279    pub fn resolve_object(&self, obj: &DynamicObject) -> Option<(ApiResource, ApiCapabilities)> {
280        self.resolve_typemeta(obj.types.as_ref()?)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::client::Body;
288    use http::{Request, Response};
289    use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
290    use kube_core::{
291        DynamicObject, TypeMeta,
292        discovery::v2::{
293            APIGroupDiscovery, APIResourceDiscovery, APIVersionDiscovery, GroupVersionKind as V2Gvk,
294        },
295    };
296    use tower_test::mock;
297
298    // Build a Discovery cache serving a single apps/v1 Deployment, with a dummy client
299    // (resolve_typemeta / resolve_object never touch the client).
300    fn test_discovery() -> Discovery {
301        let ag = APIGroupDiscovery {
302            metadata: Some(ObjectMeta {
303                name: Some("apps".to_string()),
304                ..Default::default()
305            }),
306            versions: vec![APIVersionDiscovery {
307                version: Some("v1".to_string()),
308                resources: vec![APIResourceDiscovery {
309                    resource: Some("deployments".to_string()),
310                    response_kind: Some(V2Gvk {
311                        group: None,
312                        version: None,
313                        kind: Some("Deployment".to_string()),
314                    }),
315                    scope: Some("Namespaced".to_string()),
316                    verbs: vec!["get".to_string(), "list".to_string()],
317                    ..Default::default()
318                }],
319                freshness: Some("Current".to_string()),
320            }],
321        };
322        let mut groups = HashMap::new();
323        groups.insert("apps".to_string(), ApiGroup::from_v2(ag).unwrap());
324        let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
325        let client = Client::new(mock_service, "default");
326        Discovery {
327            client,
328            groups,
329            mode: DiscoveryMode::Block(vec![]),
330        }
331    }
332
333    #[tokio::test]
334    async fn resolve_typemeta_finds_resource() {
335        let discovery = test_discovery();
336        let tm = TypeMeta {
337            api_version: "apps/v1".to_string(),
338            kind: "Deployment".to_string(),
339        };
340        let (ar, _caps) = discovery.resolve_typemeta(&tm).expect("known typemeta resolves");
341        assert_eq!(ar.group, "apps");
342        assert_eq!(ar.version, "v1");
343        assert_eq!(ar.kind, "Deployment");
344    }
345
346    #[tokio::test]
347    async fn resolve_object_finds_resource() {
348        let discovery = test_discovery();
349        let obj = DynamicObject {
350            types: Some(TypeMeta {
351                api_version: "apps/v1".to_string(),
352                kind: "Deployment".to_string(),
353            }),
354            metadata: Default::default(),
355            data: Default::default(),
356        };
357        let (ar, _caps) = discovery.resolve_object(&obj).expect("object with types resolves");
358        assert_eq!(ar.kind, "Deployment");
359        assert_eq!(ar.api_version, "apps/v1");
360    }
361
362    #[tokio::test]
363    async fn resolve_object_without_typemeta_is_none() {
364        let discovery = test_discovery();
365        let obj = DynamicObject {
366            types: None,
367            metadata: Default::default(),
368            data: Default::default(),
369        };
370        assert!(discovery.resolve_object(&obj).is_none());
371    }
372
373    #[tokio::test]
374    async fn resolve_typemeta_unknown_kind_is_none() {
375        let discovery = test_discovery();
376        let tm = TypeMeta {
377            api_version: "apps/v1".to_string(),
378            kind: "DoesNotExist".to_string(),
379        };
380        assert!(discovery.resolve_typemeta(&tm).is_none());
381    }
382}