Skip to main content

kube_client/discovery/
apigroup.rs

1use super::parse::{self, GroupVersionData};
2use crate::{Client, Error, Result, error::DiscoveryError};
3use k8s_openapi::apimachinery::pkg::apis::meta::v1::{APIGroup, APIVersions};
4pub use kube_core::discovery::{ApiCapabilities, ApiResource};
5use kube_core::{
6    Version,
7    discovery::v2::APIGroupDiscovery,
8    gvk::{GroupVersion, GroupVersionKind, ParseGroupVersionError},
9};
10use std::{cmp::Reverse, collections::HashMap, iter::Iterator};
11
12/// Describes one API groups collected resources and capabilities.
13///
14/// Each `ApiGroup` contains all data pinned to a each version.
15/// In particular, one data set within the `ApiGroup` for `"apiregistration.k8s.io"`
16/// is the subset pinned to `"v1"`; commonly referred to as `"apiregistration.k8s.io/v1"`.
17///
18/// If you know the version of the discovered group, you can fetch it directly:
19/// ```no_run
20/// use kube::{Client, api::{Api, DynamicObject}, discovery, ResourceExt};
21/// #[tokio::main]
22/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
23///     let client = Client::try_default().await?;
24///     let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?;
25///      for (apiresource, caps) in apigroup.versioned_resources("v1") {
26///          println!("Found ApiResource {}", apiresource.kind);
27///      }
28///     Ok(())
29/// }
30/// ```
31///
32/// But if you do not know this information, you can use [`ApiGroup::preferred_version_or_latest`].
33///
34/// Whichever way you choose the end result is something describing a resource and its abilities:
35/// - `Vec<(ApiResource, `ApiCapabilities)>` :: for all resources in a versioned ApiGroup
36/// - `(ApiResource, ApiCapabilities)` :: for a single kind under a versioned ApiGroud
37///
38/// These two types: [`ApiResource`], and [`ApiCapabilities`]
39/// should contain the information needed to construct an [`Api`](crate::Api) and start querying the kubernetes API.
40/// You will likely need to use [`DynamicObject`] as the generic type for Api to do this,
41/// as well as the [`ApiResource`] for the `DynamicType` for the [`Resource`] trait.
42///
43/// ```no_run
44/// use kube::{Client, api::{Api, DynamicObject}, discovery, ResourceExt};
45/// #[tokio::main]
46/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
47///     let client = Client::try_default().await?;
48///     let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?;
49///     let (ar, caps) = apigroup.recommended_kind("APIService").unwrap();
50///     let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
51///     for service in api.list(&Default::default()).await? {
52///         println!("Found APIService: {}", service.name_any());
53///     }
54///     Ok(())
55/// }
56/// ```
57///
58/// This type represents an abstraction over the native [`APIGroup`] to provide easier access to underlying group resources.
59///
60/// ### Common Pitfall
61/// Version preference and recommendations shown herein is a **group concept**, not a resource-wide concept.
62/// A common mistake is have different stored versions for resources within a group, and then receive confusing results from this module.
63/// Resources in a shared group should share versions - and transition together - to minimize confusion.
64/// See <https://kubernetes.io/docs/concepts/overview/kubernetes-api/#api-groups-and-versioning> for more info.
65///
66/// [`ApiResource`]: crate::discovery::ApiResource
67/// [`ApiCapabilities`]: crate::discovery::ApiCapabilities
68/// [`DynamicObject`]: crate::api::DynamicObject
69/// [`Resource`]: crate::Resource
70/// [`ApiGroup::preferred_version_or_latest`]: crate::discovery::ApiGroup::preferred_version_or_latest
71/// [`ApiGroup::versioned_resources`]: crate::discovery::ApiGroup::versioned_resources
72/// [`ApiGroup::recommended_resources`]: crate::discovery::ApiGroup::recommended_resources
73/// [`ApiGroup::recommended_kind`]: crate::discovery::ApiGroup::recommended_kind
74pub struct ApiGroup {
75    /// Name of the group e.g. apiregistration.k8s.io
76    name: String,
77    /// List of resource information, capabilities at particular versions
78    data: Vec<GroupVersionData>,
79    /// Preferred version if exported by the `APIGroup`
80    preferred: Option<String>,
81}
82
83/// Internal queriers to convert from an APIGroup (or APIVersions for core) to our ApiGroup
84///
85/// These queriers ignore groups with empty versions.
86/// This ensures that `ApiGroup::preferred_version_or_latest` always have an answer.
87/// On construction, they also sort the internal vec of GroupVersionData according to `Version`.
88impl ApiGroup {
89    pub(crate) async fn query_apis(client: &Client, g: APIGroup) -> Result<Self> {
90        tracing::debug!(name = g.name.as_str(), "Listing group versions");
91        let key = g.name;
92        if g.versions.is_empty() {
93            return Err(Error::Discovery(DiscoveryError::EmptyApiGroup(key)));
94        }
95        let mut data = vec![];
96        for vers in &g.versions {
97            let resources = client.list_api_group_resources(&vers.group_version).await?;
98            data.push(GroupVersionData::new(vers.version.clone(), resources)?);
99        }
100        let mut group = ApiGroup {
101            name: key,
102            data,
103            preferred: g.preferred_version.map(|v| v.version),
104        };
105        group.sort_versions();
106        Ok(group)
107    }
108
109    pub(crate) async fn query_core(client: &Client, coreapis: APIVersions) -> Result<Self> {
110        let mut data = vec![];
111        let key = ApiGroup::CORE_GROUP.to_string();
112        if coreapis.versions.is_empty() {
113            return Err(Error::Discovery(DiscoveryError::EmptyApiGroup(key)));
114        }
115        for v in coreapis.versions {
116            let resources = client.list_core_api_resources(&v).await?;
117            data.push(GroupVersionData::new(v, resources)?);
118        }
119        let mut group = ApiGroup {
120            name: ApiGroup::CORE_GROUP.to_string(),
121            data,
122            preferred: Some("v1".to_string()),
123        };
124        group.sort_versions();
125        Ok(group)
126    }
127
128    /// Create an ApiGroup from aggregated discovery v2 types
129    ///
130    /// This is used by `Discovery::run_aggregated()` to convert the aggregated
131    /// discovery response into the same format used by regular discovery.
132    /// Takes ownership to avoid cloning internal data.
133    pub(crate) fn from_v2(ag: APIGroupDiscovery) -> Result<Self> {
134        let name = ag.metadata.and_then(|m| m.name).unwrap_or_default();
135
136        if ag.versions.is_empty() {
137            return Err(Error::Discovery(DiscoveryError::EmptyApiGroup(name)));
138        }
139
140        // Preferred version is the first one in the list (they're sorted by preference)
141        let preferred = ag.versions.first().and_then(|v| v.version.clone());
142
143        let data: Vec<GroupVersionData> = ag
144            .versions
145            .into_iter()
146            .map(|ver| GroupVersionData::from_v2(&name, ver))
147            .collect();
148
149        let mut group = ApiGroup {
150            name,
151            data,
152            preferred,
153        };
154        group.sort_versions();
155        Ok(group)
156    }
157
158    fn sort_versions(&mut self) {
159        self.data
160            .sort_by_cached_key(|gvd| Reverse(Version::parse(gvd.version.as_str()).priority()))
161    }
162
163    // shortcut method to give cheapest return for a single GVK
164    pub(crate) async fn query_gvk(
165        client: &Client,
166        gvk: &GroupVersionKind,
167    ) -> Result<(ApiResource, ApiCapabilities)> {
168        let apiver = gvk.api_version();
169        let list = if gvk.group.is_empty() {
170            client.list_core_api_resources(&apiver).await?
171        } else {
172            client.list_api_group_resources(&apiver).await?
173        };
174        for res in &list.resources {
175            if res.kind == gvk.kind && !res.name.contains('/') {
176                let ar = parse::parse_apiresource(res, &list.group_version).map_err(
177                    |ParseGroupVersionError(s)| Error::Discovery(DiscoveryError::InvalidGroupVersion(s)),
178                )?;
179                let caps = parse::parse_apicapabilities(&list, &res.name)?;
180                return Ok((ar, caps));
181            }
182        }
183        Err(Error::Discovery(DiscoveryError::MissingKind(format!("{gvk:?}"))))
184    }
185
186    // shortcut method to give cheapest return for a pinned group
187    pub(crate) async fn query_gv(client: &Client, gv: &GroupVersion) -> Result<Self> {
188        let apiver = gv.api_version();
189        let list = if gv.group.is_empty() {
190            client.list_core_api_resources(&apiver).await?
191        } else {
192            client.list_api_group_resources(&apiver).await?
193        };
194        let data = GroupVersionData::new(gv.version.clone(), list)?;
195        let group = ApiGroup {
196            name: gv.group.clone(),
197            data: vec![data],
198            preferred: Some(gv.version.clone()), // you preferred what you asked for
199        };
200        Ok(group)
201    }
202}
203
204/// Public ApiGroup interface
205impl ApiGroup {
206    /// Core group name
207    pub const CORE_GROUP: &'static str = "";
208
209    /// Returns the name of this group.
210    pub fn name(&self) -> &str {
211        &self.name
212    }
213
214    /// Returns served versions (e.g. `["v1", "v2beta1"]`) of this group.
215    ///
216    /// This [`Iterator`] is never empty, and returns elements in descending order of [`Version`](kube_core::Version):
217    /// - Stable versions (with the last being the first)
218    /// - Beta versions (with the last being the first)
219    /// - Alpha versions (with the last being the first)
220    /// - Other versions, alphabetically
221    pub fn versions(&self) -> impl Iterator<Item = &str> {
222        self.data.as_slice().iter().map(|gvd| gvd.version.as_str())
223    }
224
225    /// Returns preferred version for working with given group.
226    ///
227    /// Please note the [ApiGroup Common Pitfall](ApiGroup#common-pitfall).
228    pub fn preferred_version(&self) -> Option<&str> {
229        self.preferred.as_deref()
230    }
231
232    /// Returns the preferred version or latest version for working with given group.
233    ///
234    /// If the server does not recommend a version, we pick the "most stable and most recent" version
235    /// in accordance with [kubernetes version priority](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#version-priority)
236    /// via the descending sort order from [`Version`](kube_core::Version).
237    ///
238    /// Please note the [ApiGroup Common Pitfall](ApiGroup#common-pitfall).
239    pub fn preferred_version_or_latest(&self) -> &str {
240        // NB: self.versions is non-empty by construction in ApiGroup
241        self.preferred
242            .as_deref()
243            .unwrap_or_else(|| self.versions().next().unwrap())
244    }
245
246    /// Returns the resources in the group at an arbitrary version string.
247    ///
248    /// If the group does not support this version, the returned vector is empty.
249    ///
250    /// If you are looking for the api recommended list of resources, or just on particular kind
251    /// consider [`ApiGroup::recommended_resources`] or [`ApiGroup::recommended_kind`] instead.
252    pub fn versioned_resources(&self, ver: &str) -> Vec<(ApiResource, ApiCapabilities)> {
253        self.data
254            .iter()
255            .find(|gvd| gvd.version == ver)
256            .map(|gvd| gvd.resources.clone())
257            .unwrap_or_default()
258    }
259
260    /// Returns the recommended (preferred or latest) versioned resources in the group
261    ///
262    /// ```no_run
263    /// use kube::{Client, api::{Api, DynamicObject}, discovery::{self, verbs}, ResourceExt};
264    /// #[tokio::main]
265    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
266    ///     let client = Client::try_default().await?;
267    ///     let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?;
268    ///     for (ar, caps) in apigroup.recommended_resources() {
269    ///         if !caps.supports_operation(verbs::LIST) {
270    ///             continue;
271    ///         }
272    ///         let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
273    ///         for inst in api.list(&Default::default()).await? {
274    ///             println!("Found {}: {}", ar.kind, inst.name_any());
275    ///         }
276    ///     }
277    ///     Ok(())
278    /// }
279    /// ```
280    ///
281    /// This is equivalent to taking the [`ApiGroup::versioned_resources`] at the [`ApiGroup::preferred_version_or_latest`].
282    ///
283    /// Please note the [ApiGroup Common Pitfall](ApiGroup#common-pitfall).
284    pub fn recommended_resources(&self) -> Vec<(ApiResource, ApiCapabilities)> {
285        let ver = self.preferred_version_or_latest();
286        self.versioned_resources(ver)
287    }
288
289    ///  Returns all resources in the group at their the most stable respective version
290    ///
291    /// ```no_run
292    /// use kube::{Client, api::{Api, DynamicObject}, discovery::{self, verbs}, ResourceExt};
293    /// #[tokio::main]
294    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
295    ///     let client = Client::try_default().await?;
296    ///     let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?;
297    ///     for (ar, caps) in apigroup.resources_by_stability() {
298    ///         if !caps.supports_operation(verbs::LIST) {
299    ///             continue;
300    ///         }
301    ///         let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
302    ///         for inst in api.list(&Default::default()).await? {
303    ///             println!("Found {}: {}", ar.kind, inst.name_any());
304    ///         }
305    ///     }
306    ///     Ok(())
307    /// }
308    /// ```
309    /// See an example in [examples/kubectl.rs](https://github.com/kube-rs/kube/blob/main/examples/kubectl.rs)
310    pub fn resources_by_stability(&self) -> Vec<(ApiResource, ApiCapabilities)> {
311        let mut lookup = HashMap::new();
312        self.data.iter().for_each(|gvd| {
313            gvd.resources.iter().for_each(|resource| {
314                lookup
315                    .entry(resource.0.kind.clone())
316                    .or_insert_with(Vec::new)
317                    .push(resource);
318            })
319        });
320        lookup
321            .into_values()
322            .map(|mut v| {
323                v.sort_by_cached_key(|(ar, _)| Reverse(Version::parse(ar.version.as_str()).priority()));
324                v[0].to_owned()
325            })
326            .collect()
327    }
328
329    /// Returns the recommended version of the `kind` in the recommended resources (if found)
330    ///
331    /// ```no_run
332    /// use kube::{Client, api::{Api, DynamicObject}, discovery, ResourceExt};
333    /// #[tokio::main]
334    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
335    ///     let client = Client::try_default().await?;
336    ///     let apigroup = discovery::group(&client, "apiregistration.k8s.io").await?;
337    ///     let (ar, caps) = apigroup.recommended_kind("APIService").unwrap();
338    ///     let api: Api<DynamicObject> = Api::all_with(client.clone(), &ar);
339    ///     for service in api.list(&Default::default()).await? {
340    ///         println!("Found APIService: {}", service.name_any());
341    ///     }
342    ///     Ok(())
343    /// }
344    /// ```
345    ///
346    /// This is equivalent to filtering the [`ApiGroup::versioned_resources`] at [`ApiGroup::preferred_version_or_latest`] against a chosen `kind`.
347    pub fn recommended_kind(&self, kind: &str) -> Option<(ApiResource, ApiCapabilities)> {
348        let ver = self.preferred_version_or_latest();
349        for (ar, caps) in self.versioned_resources(ver) {
350            if ar.kind == kind {
351                return Some((ar, caps));
352            }
353        }
354        None
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
362    use kube_core::discovery::{
363        Scope,
364        v2::{APIGroupDiscovery, APIResourceDiscovery, APIVersionDiscovery, GroupVersionKind},
365    };
366
367    fn make_v2_resource(resource: &str, kind: &str, scope: &str, verbs: Vec<&str>) -> APIResourceDiscovery {
368        APIResourceDiscovery {
369            resource: Some(resource.to_string()),
370            response_kind: Some(GroupVersionKind {
371                group: None,
372                version: None,
373                kind: Some(kind.to_string()),
374            }),
375            scope: Some(scope.to_string()),
376            verbs: verbs.into_iter().map(String::from).collect(),
377            ..Default::default()
378        }
379    }
380
381    #[test]
382    fn test_api_group_from_v2_apps() {
383        let ag = APIGroupDiscovery {
384            metadata: Some(ObjectMeta {
385                name: Some("apps".to_string()),
386                ..Default::default()
387            }),
388            versions: vec![APIVersionDiscovery {
389                version: Some("v1".to_string()),
390                resources: vec![
391                    make_v2_resource("deployments", "Deployment", "Namespaced", vec![
392                        "get", "list", "create",
393                    ]),
394                    make_v2_resource("replicasets", "ReplicaSet", "Namespaced", vec!["get", "list"]),
395                ],
396                freshness: Some("Current".to_string()),
397            }],
398        };
399
400        let group = ApiGroup::from_v2(ag).unwrap();
401
402        assert_eq!(group.name(), "apps");
403        assert_eq!(group.preferred_version(), Some("v1"));
404        assert_eq!(group.versions().collect::<Vec<_>>(), vec!["v1"]);
405
406        let resources = group.recommended_resources();
407        assert_eq!(resources.len(), 2);
408
409        let (deploy_ar, deploy_caps) = group.recommended_kind("Deployment").unwrap();
410        assert_eq!(deploy_ar.group, "apps");
411        assert_eq!(deploy_ar.version, "v1");
412        assert_eq!(deploy_ar.api_version, "apps/v1");
413        assert_eq!(deploy_ar.kind, "Deployment");
414        assert_eq!(deploy_caps.scope, Scope::Namespaced);
415    }
416
417    #[test]
418    fn test_api_group_from_v2_core() {
419        let ag = APIGroupDiscovery {
420            metadata: Some(ObjectMeta {
421                name: Some("".to_string()), // core group has empty name
422                ..Default::default()
423            }),
424            versions: vec![APIVersionDiscovery {
425                version: Some("v1".to_string()),
426                resources: vec![
427                    make_v2_resource("pods", "Pod", "Namespaced", vec!["get", "list", "watch"]),
428                    make_v2_resource("nodes", "Node", "Cluster", vec!["get", "list"]),
429                ],
430                freshness: Some("Current".to_string()),
431            }],
432        };
433
434        let group = ApiGroup::from_v2(ag).unwrap();
435
436        assert_eq!(group.name(), "");
437        assert_eq!(group.preferred_version(), Some("v1"));
438
439        let (pod_ar, pod_caps) = group.recommended_kind("Pod").unwrap();
440        assert_eq!(pod_ar.group, "");
441        assert_eq!(pod_ar.api_version, "v1"); // core group: no prefix
442        assert_eq!(pod_caps.scope, Scope::Namespaced);
443
444        let (node_ar, node_caps) = group.recommended_kind("Node").unwrap();
445        assert_eq!(node_ar.kind, "Node");
446        assert_eq!(node_caps.scope, Scope::Cluster);
447    }
448
449    #[test]
450    fn test_api_group_from_v2_multiple_versions() {
451        // Use autoscaling group which has multiple major versions (v1, v2)
452        // Major versions are never removed per deprecation policy Rule #4a
453        let ag = APIGroupDiscovery {
454            metadata: Some(ObjectMeta {
455                name: Some("autoscaling".to_string()),
456                ..Default::default()
457            }),
458            versions: vec![
459                // First version is preferred
460                APIVersionDiscovery {
461                    version: Some("v2".to_string()),
462                    resources: vec![make_v2_resource(
463                        "horizontalpodautoscalers",
464                        "HorizontalPodAutoscaler",
465                        "Namespaced",
466                        vec!["get", "list"],
467                    )],
468                    freshness: Some("Current".to_string()),
469                },
470                APIVersionDiscovery {
471                    version: Some("v1".to_string()),
472                    resources: vec![make_v2_resource(
473                        "horizontalpodautoscalers",
474                        "HorizontalPodAutoscaler",
475                        "Namespaced",
476                        vec!["get"],
477                    )],
478                    freshness: Some("Current".to_string()),
479                },
480            ],
481        };
482
483        let group = ApiGroup::from_v2(ag).unwrap();
484
485        assert_eq!(group.name(), "autoscaling");
486        assert_eq!(group.preferred_version(), Some("v2"));
487        assert_eq!(group.versions().collect::<Vec<_>>(), vec!["v2", "v1"]);
488
489        // Recommended should be v2
490        let (ar, _) = group.recommended_kind("HorizontalPodAutoscaler").unwrap();
491        assert_eq!(ar.version, "v2");
492
493        // Can also get v1 explicitly
494        let v1_resources = group.versioned_resources("v1");
495        assert_eq!(v1_resources.len(), 1);
496        assert_eq!(v1_resources[0].0.version, "v1");
497    }
498
499    #[test]
500    fn test_api_group_from_v2_empty_versions_error() {
501        let ag = APIGroupDiscovery {
502            metadata: Some(ObjectMeta {
503                name: Some("empty".to_string()),
504                ..Default::default()
505            }),
506            versions: vec![], // empty!
507        };
508
509        let result = ApiGroup::from_v2(ag);
510        assert!(result.is_err());
511    }
512
513    #[test]
514    fn test_resources_by_stability() {
515        let ac = ApiCapabilities {
516            scope: Scope::Namespaced,
517            subresources: vec![],
518            operations: vec![],
519        };
520
521        let testlowversioncr_v1alpha1 = ApiResource {
522            group: String::from("kube.rs"),
523            version: String::from("v1alpha1"),
524            kind: String::from("TestLowVersionCr"),
525            api_version: String::from("kube.rs/v1alpha1"),
526            plural: String::from("testlowversioncrs"),
527        };
528
529        let testcr_v1 = ApiResource {
530            group: String::from("kube.rs"),
531            version: String::from("v1"),
532            kind: String::from("TestCr"),
533            api_version: String::from("kube.rs/v1"),
534            plural: String::from("testcrs"),
535        };
536
537        let testcr_v2alpha1 = ApiResource {
538            group: String::from("kube.rs"),
539            version: String::from("v2alpha1"),
540            kind: String::from("TestCr"),
541            api_version: String::from("kube.rs/v2alpha1"),
542            plural: String::from("testcrs"),
543        };
544
545        let group = ApiGroup {
546            name: "kube.rs".to_string(),
547            data: vec![
548                GroupVersionData {
549                    version: "v1alpha1".to_string(),
550                    resources: vec![(testlowversioncr_v1alpha1, ac.clone())],
551                },
552                GroupVersionData {
553                    version: "v1".to_string(),
554                    resources: vec![(testcr_v1, ac.clone())],
555                },
556                GroupVersionData {
557                    version: "v2alpha1".to_string(),
558                    resources: vec![(testcr_v2alpha1, ac)],
559                },
560            ],
561            preferred: Some(String::from("v1")),
562        };
563
564        let resources = group.resources_by_stability();
565        assert!(
566            resources
567                .iter()
568                .any(|(ar, _)| ar.kind == "TestCr" && ar.version == "v1"),
569            "wrong stable version"
570        );
571        assert!(
572            resources
573                .iter()
574                .any(|(ar, _)| ar.kind == "TestLowVersionCr" && ar.version == "v1alpha1"),
575            "lost low version resource"
576        );
577    }
578}