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}