From 25d5761bfa0603c77cf5efefb14fd2e63a03a773 Mon Sep 17 00:00:00 2001 From: Sean Sullivan Date: Thu, 3 Nov 2022 11:09:58 -0700 Subject: [PATCH] Aggregated discovery client Kubernetes-commit: b8fe2c7b8061e6ab0b093a49351bac256606da4a --- discovery/aggregated_discovery.go | 108 ++ discovery/aggregated_discovery_test.go | 644 +++++++++ discovery/cached/disk/cached_discovery.go | 9 +- .../cached/disk/cached_discovery_test.go | 512 ++++++- discovery/cached/memory/memcache.go | 72 +- discovery/cached/memory/memcache_test.go | 948 +++++++++++++ discovery/discovery_client.go | 202 ++- discovery/discovery_client_test.go | 1240 +++++++++++++++++ 8 files changed, 3696 insertions(+), 39 deletions(-) create mode 100644 discovery/aggregated_discovery.go create mode 100644 discovery/aggregated_discovery_test.go diff --git a/discovery/aggregated_discovery.go b/discovery/aggregated_discovery.go new file mode 100644 index 0000000000..033a4c8fc3 --- /dev/null +++ b/discovery/aggregated_discovery.go @@ -0,0 +1,108 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package discovery + +import ( + "fmt" + + apidiscovery "k8s.io/api/apidiscovery/v2beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// SplitGroupsAndResources transforms "aggregated" discovery top-level structure into +// the previous "unaggregated" discovery groups and resources. +func SplitGroupsAndResources(aggregatedGroups apidiscovery.APIGroupDiscoveryList) (*metav1.APIGroupList, map[schema.GroupVersion]*metav1.APIResourceList) { + // Aggregated group list will contain the entirety of discovery, including + // groups, versions, and resources. + groups := []*metav1.APIGroup{} + resourcesByGV := map[schema.GroupVersion]*metav1.APIResourceList{} + for _, aggGroup := range aggregatedGroups.Items { + group, resources := convertAPIGroup(aggGroup) + groups = append(groups, group) + for gv, resourceList := range resources { + resourcesByGV[gv] = resourceList + } + } + // Transform slice of groups to group list before returning. + groupList := &metav1.APIGroupList{} + groupList.Groups = make([]metav1.APIGroup, 0, len(groups)) + for _, group := range groups { + groupList.Groups = append(groupList.Groups, *group) + } + return groupList, resourcesByGV +} + +// convertAPIGroup tranforms an "aggregated" APIGroupDiscovery to an "legacy" APIGroup, +// also returning the map of APIResourceList for resources within GroupVersions. +func convertAPIGroup(g apidiscovery.APIGroupDiscovery) (*metav1.APIGroup, map[schema.GroupVersion]*metav1.APIResourceList) { + // Iterate through versions to convert to group and resources. + group := &metav1.APIGroup{} + gvResources := map[schema.GroupVersion]*metav1.APIResourceList{} + group.Name = g.ObjectMeta.Name + for i, v := range g.Versions { + version := metav1.GroupVersionForDiscovery{} + gv := schema.GroupVersion{Group: g.Name, Version: v.Version} + version.GroupVersion = gv.String() + version.Version = v.Version + group.Versions = append(group.Versions, version) + if i == 0 { + group.PreferredVersion = version + } + resourceList := &metav1.APIResourceList{} + resourceList.GroupVersion = gv.String() + for _, r := range v.Resources { + resource := convertAPIResource(r) + resourceList.APIResources = append(resourceList.APIResources, resource) + // Subresources field in new format get transformed into full APIResources. + for _, subresource := range r.Subresources { + sr := convertAPISubresource(resource, subresource) + resourceList.APIResources = append(resourceList.APIResources, sr) + } + } + gvResources[gv] = resourceList + } + return group, gvResources +} + +// convertAPIResource tranforms a APIResourceDiscovery to an APIResource. +func convertAPIResource(in apidiscovery.APIResourceDiscovery) metav1.APIResource { + return metav1.APIResource{ + Name: in.Resource, + SingularName: in.SingularResource, + Namespaced: in.Scope == apidiscovery.ScopeNamespace, + Group: in.ResponseKind.Group, + Version: in.ResponseKind.Version, + Kind: in.ResponseKind.Kind, + Verbs: in.Verbs, + ShortNames: in.ShortNames, + Categories: in.Categories, + } +} + +// convertAPISubresource tranforms a APISubresourceDiscovery to an APIResource. +func convertAPISubresource(parent metav1.APIResource, in apidiscovery.APISubresourceDiscovery) metav1.APIResource { + return metav1.APIResource{ + Name: fmt.Sprintf("%s/%s", parent.Name, in.Subresource), + SingularName: parent.SingularName, + Namespaced: parent.Namespaced, + Group: in.ResponseKind.Group, + Version: in.ResponseKind.Version, + Kind: in.ResponseKind.Kind, + Verbs: in.Verbs, + } +} diff --git a/discovery/aggregated_discovery_test.go b/discovery/aggregated_discovery_test.go new file mode 100644 index 0000000000..b232e1a0b2 --- /dev/null +++ b/discovery/aggregated_discovery_test.go @@ -0,0 +1,644 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package discovery + +import ( + "testing" + + "github.com/stretchr/testify/assert" + apidiscovery "k8s.io/api/apidiscovery/v2beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestSplitGroupsAndResources(t *testing.T) { + tests := []struct { + name string + agg apidiscovery.APIGroupDiscoveryList + expectedGroups metav1.APIGroupList + expectedGVResources map[schema.GroupVersion]*metav1.APIResourceList + }{ + { + name: "Aggregated discovery: core/v1 group and pod resource", + agg: apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroups: metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "v1", + Version: "v1", + }, + }, + }, + }, + expectedGVResources: map[schema.GroupVersion]*metav1.APIResourceList{ + {Group: "", Version: "v1"}: { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "pods", + Namespaced: true, + Group: "", + Version: "v1", + Kind: "Pod", + }, + }, + }, + }, + }, + { + name: "Aggregated discovery: 1 group/1 resources at /api, 1 group/2 versions/1 resources at /apis", + agg: apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v2", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v2", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroups: metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "apps", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "apps/v2", + Version: "v2", + }, + { + GroupVersion: "apps/v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "apps/v2", + Version: "v2", + }, + }, + }, + }, + expectedGVResources: map[schema.GroupVersion]*metav1.APIResourceList{ + {Group: "apps", Version: "v1"}: { + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + { + Name: "deployments", + Namespaced: true, + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + }, + }, + {Group: "apps", Version: "v2"}: { + GroupVersion: "apps/v2", + APIResources: []metav1.APIResource{ + { + Name: "deployments", + Namespaced: true, + Group: "apps", + Version: "v2", + Kind: "Deployment", + }, + }, + }, + }, + }, + { + name: "Aggregated discovery: 1 group/2 resources at /api, 1 group/2 resources at /apis", + agg: apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "services", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroups: metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "v1", + Version: "v1", + }, + }, + { + Name: "apps", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "apps/v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "apps/v1", + Version: "v1", + }, + }, + }, + }, + expectedGVResources: map[schema.GroupVersion]*metav1.APIResourceList{ + {Group: "", Version: "v1"}: { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "pods", + Namespaced: true, + Group: "", + Version: "v1", + Kind: "Pod", + }, + { + Name: "services", + Namespaced: true, + Group: "", + Version: "v1", + Kind: "Service", + }, + }, + }, + {Group: "apps", Version: "v1"}: { + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + { + Name: "deployments", + Namespaced: true, + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + { + Name: "statefulsets", + Namespaced: true, + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + }, + }, + }, + }, + { + name: "Aggregated discovery: multiple groups with cluster-scoped resources", + agg: apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "namespaces", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Namespace", + }, + Scope: apidiscovery.ScopeCluster, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rbac.authorization.k8s.io", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "roles", + ResponseKind: &metav1.GroupVersionKind{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "Role", + }, + Scope: apidiscovery.ScopeCluster, + }, + { + Resource: "clusterroles", + ResponseKind: &metav1.GroupVersionKind{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + }, + Scope: apidiscovery.ScopeCluster, + }, + }, + }, + }, + }, + }, + }, + expectedGroups: metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "v1", + Version: "v1", + }, + }, + { + Name: "rbac.authorization.k8s.io", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "rbac.authorization.k8s.io/v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "rbac.authorization.k8s.io/v1", + Version: "v1", + }, + }, + }, + }, + expectedGVResources: map[schema.GroupVersion]*metav1.APIResourceList{ + {Group: "", Version: "v1"}: { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + { + Name: "pods", + Namespaced: true, + Group: "", + Version: "v1", + Kind: "Pod", + }, + { + Name: "namespaces", + Namespaced: false, + Group: "", + Version: "v1", + Kind: "Namespace", + }, + }, + }, + {Group: "rbac.authorization.k8s.io", Version: "v1"}: { + GroupVersion: "rbac.authorization.k8s.io/v1", + APIResources: []metav1.APIResource{ + { + Name: "roles", + Namespaced: false, + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "Role", + }, + { + Name: "clusterroles", + Namespaced: false, + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + }, + }, + }, + }, + }, + { + name: "Aggregated discovery with single subresource", + agg: apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + SingularResource: "deployment", + ShortNames: []string{"deploy"}, + Verbs: []string{"parentverb1", "parentverb2", "parentverb3", "parentverb4"}, + Categories: []string{"all", "testcategory"}, + Subresources: []apidiscovery.APISubresourceDiscovery{ + { + Subresource: "scale", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Verbs: []string{"get", "patch", "update"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedGroups: metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "apps", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "apps/v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "apps/v1", + Version: "v1", + }, + }, + }, + }, + expectedGVResources: map[schema.GroupVersion]*metav1.APIResourceList{ + {Group: "apps", Version: "v1"}: { + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + { + Name: "deployments", + SingularName: "deployment", + Namespaced: true, + Group: "apps", + Version: "v1", + Kind: "Deployment", + Verbs: []string{"parentverb1", "parentverb2", "parentverb3", "parentverb4"}, + ShortNames: []string{"deploy"}, + Categories: []string{"all", "testcategory"}, + }, + { + Name: "deployments/scale", + SingularName: "deployment", + Namespaced: true, + Group: "apps", + Version: "v1", + Kind: "Deployment", + Verbs: []string{"get", "patch", "update"}, + }, + }, + }, + }, + }, + { + name: "Aggregated discovery with multiple subresources", + agg: apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + SingularResource: "deployment", + Subresources: []apidiscovery.APISubresourceDiscovery{ + { + Subresource: "scale", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Verbs: []string{"get", "patch", "update"}, + }, + { + Subresource: "status", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Verbs: []string{"get", "patch", "update"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedGroups: metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "apps", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "apps/v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "apps/v1", + Version: "v1", + }, + }, + }, + }, + expectedGVResources: map[schema.GroupVersion]*metav1.APIResourceList{ + {Group: "apps", Version: "v1"}: { + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + { + Name: "deployments", + SingularName: "deployment", + Namespaced: true, + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + { + Name: "deployments/scale", + SingularName: "deployment", + Namespaced: true, + Group: "apps", + Version: "v1", + Kind: "Deployment", + Verbs: []string{"get", "patch", "update"}, + }, + { + Name: "deployments/status", + SingularName: "deployment", + Namespaced: true, + Group: "apps", + Version: "v1", + Kind: "Deployment", + Verbs: []string{"get", "patch", "update"}, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + apiGroups, resourcesByGV := SplitGroupsAndResources(test.agg) + assert.Equal(t, test.expectedGroups, *apiGroups) + assert.Equal(t, test.expectedGVResources, resourcesByGV) + } +} diff --git a/discovery/cached/disk/cached_discovery.go b/discovery/cached/disk/cached_discovery.go index 50b6d410e1..c411acf7c8 100644 --- a/discovery/cached/disk/cached_discovery.go +++ b/discovery/cached/disk/cached_discovery.go @@ -32,6 +32,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/openapi" cachedopenapi "k8s.io/client-go/openapi/cached" @@ -271,6 +272,9 @@ func (d *CachedDiscoveryClient) Invalidate() { d.fresh = true d.invalidated = true d.openapiClient = nil + if ad, ok := d.delegate.(discovery.CachedDiscoveryInterface); ok { + ad.Invalidate() + } } // NewCachedDiscoveryClientForConfig creates a new DiscoveryClient for the given config, and wraps @@ -297,7 +301,10 @@ func NewCachedDiscoveryClientForConfig(config *restclient.Config, discoveryCache return nil, err } - return newCachedDiscoveryClient(discoveryClient, discoveryCacheDir, ttl), nil + // The delegate caches the discovery groups and resources (memcache). "ServerGroups", + // which usually only returns (and caches) the groups, can now store the resources as + // well if the server supports the newer aggregated discovery format. + return newCachedDiscoveryClient(memory.NewMemCacheClient(discoveryClient), discoveryCacheDir, ttl), nil } // NewCachedDiscoveryClient creates a new DiscoveryClient. cacheDirectory is the directory where discovery docs are held. It must be unique per host:port combination to work well. diff --git a/discovery/cached/disk/cached_discovery_test.go b/discovery/cached/disk/cached_discovery_test.go index ac30604ab4..bf38db737d 100644 --- a/discovery/cached/disk/cached_discovery_test.go +++ b/discovery/cached/disk/cached_discovery_test.go @@ -17,6 +17,9 @@ limitations under the License. package disk import ( + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -27,10 +30,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + apidiscovery "k8s.io/api/apidiscovery/v2beta1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/discovery" "k8s.io/client-go/openapi" @@ -131,13 +136,13 @@ func TestNewCachedDiscoveryClient_PathPerm(t *testing.T) { // successive calls func TestOpenAPIDiskCache(t *testing.T) { // Create discovery cache dir (unused) - discoCache, err := os.MkdirTemp("", "") + discoCache, err := os.MkdirTemp("", "test-cached-discovery-client-disco-*") require.NoError(t, err) os.RemoveAll(discoCache) defer os.RemoveAll(discoCache) // Create http cache dir - httpCache, err := os.MkdirTemp("", "") + httpCache, err := os.MkdirTemp("", "test-cached-discovery-client-http-*") require.NoError(t, err) os.RemoveAll(httpCache) defer os.RemoveAll(httpCache) @@ -210,7 +215,6 @@ func TestOpenAPIDiskCache(t *testing.T) { // Ensure schema call is still served from disk _, err = newPaths[k].Schema(contentType) assert.NoError(t, err) - assert.Equal(t, i, fakeServer.RequestCounters["/openapi/v3"]) assert.Equal(t, 1, fakeServer.RequestCounters[path]) } }) @@ -218,6 +222,481 @@ func TestOpenAPIDiskCache(t *testing.T) { } +// Tests function "ServerGroups" when the "unaggregated" discovery is returned. +func TestCachedDiscoveryClientUnaggregatedServerGroups(t *testing.T) { + tests := []struct { + name string + corev1 *metav1.APIVersions + apis *metav1.APIGroupList + expectedGroupNames []string + expectedGroupVersions []string + }{ + { + name: "Legacy discovery format: 1 version at /api, 1 group at /apis", + corev1: &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + }, + apis: &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "extensions/v1beta1"}, + }, + }, + }, + }, + expectedGroupNames: []string{"", "extensions"}, + expectedGroupVersions: []string{"v1", "extensions/v1beta1"}, + }, + { + name: "Legacy discovery format: 1 version at /api, 2 groups/1 version at /apis", + corev1: &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + }, + apis: &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "apps", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "apps/v1"}, + }, + }, + { + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "extensions/v1beta1"}, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps", "extensions"}, + expectedGroupVersions: []string{"v1", "apps/v1", "extensions/v1beta1"}, + }, + { + name: "Legacy discovery format: 1 version at /api, 2 groups/2 versions at /apis", + corev1: &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + }, + apis: &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "batch", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "batch/v1"}, + }, + }, + { + Name: "batch", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "batch/v1beta1"}, + }, + }, + { + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "extensions/v1beta1"}, + }, + }, + { + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "extensions/v1alpha1"}, + }, + }, + }, + }, + expectedGroupNames: []string{ + "", + "batch", + "extensions", + }, + expectedGroupVersions: []string{ + "v1", + "batch/v1", + "batch/v1beta1", + "extensions/v1beta1", + "extensions/v1alpha1", + }, + }, + } + + for _, test := range tests { + // Create discovery cache dir + discoCache, err := os.MkdirTemp("", "test-cached-discovery-client-disco-*") + require.NoError(t, err) + os.RemoveAll(discoCache) + defer os.RemoveAll(discoCache) + // Create http cache dir (unused) + httpCache, err := os.MkdirTemp("", "test-cached-discovery-client-http-*") + require.NoError(t, err) + os.RemoveAll(httpCache) + defer os.RemoveAll(httpCache) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var body interface{} + switch req.URL.Path { + case "/api": + body = test.corev1 + case "/apis": + body = test.apis + default: + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(body) + require.NoError(t, err) + // Content-type is "unaggregated" discovery format -- no resources returned. + w.Header().Set("Content-Type", discovery.AcceptV1) + w.WriteHeader(http.StatusOK) + w.Write(output) + })) + defer server.Close() + client, err := NewCachedDiscoveryClientForConfig( + &restclient.Config{Host: server.URL}, + discoCache, + httpCache, + 1*time.Nanosecond, + ) + require.NoError(t, err) + apiGroupList, err := client.ServerGroups() + require.NoError(t, err) + // Discovery groups cached in servergroups.json file. + numFound, err := numFilesFound(discoCache, "servergroups.json") + assert.NoError(t, err) + assert.Equal(t, 1, numFound, + "%s: expected 1 discovery cache file servergroups.json found, got %d", test.name, numFound) + // Test expected groups returned by server groups. + expectedGroupNames := sets.NewString(test.expectedGroupNames...) + actualGroupNames := sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + // Test the expected group versions for the aggregated discovery is correct. + expectedGroupVersions := sets.NewString(test.expectedGroupVersions...) + actualGroupVersions := sets.NewString(groupVersionsFromGroups(apiGroupList)...) + assert.True(t, expectedGroupVersions.Equal(actualGroupVersions), + "%s: Expected group/versions (%s), got (%s)", test.name, expectedGroupVersions.List(), actualGroupVersions.List()) + } +} + +// Aggregated discovery format returned +func TestCachedDiscoveryClientAggregatedServerGroups(t *testing.T) { + tests := []struct { + name string + corev1 *apidiscovery.APIGroupDiscoveryList + apis *apidiscovery.APIGroupDiscoveryList + expectedGroupNames []string + expectedGroupVersions []string + expectedPreferredVersions []string + }{ + { + name: "Aggregated cached discovery: 1 group/1 version at /api, 1 group/1 version at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1"}, + expectedPreferredVersions: []string{"v1", "apps/v1"}, + }, + { + name: "Aggregated discovery: 1 group/1 version at /api, 1 group/2 versions at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + // v2 is preferred since it is first + { + Version: "v2", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v2", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1", "apps/v2"}, + expectedPreferredVersions: []string{"v1", "apps/v2"}, + }, + { + name: "Aggregated discovery: /api returns nothing, 2 groups at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{}, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + // v1 is preferred since it is first + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1beta1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1beta1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1beta1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"apps", "batch"}, + expectedGroupVersions: []string{"apps/v1", "batch/v1", "batch/v1beta1"}, + expectedPreferredVersions: []string{"apps/v1", "batch/v1"}, + }, + } + + for _, test := range tests { + // Create discovery cache dir + discoCache, err := os.MkdirTemp("", "test-cached-discovery-client-disco-*") + require.NoError(t, err) + os.RemoveAll(discoCache) + defer os.RemoveAll(discoCache) + // Create http cache dir (unused) + httpCache, err := os.MkdirTemp("", "test-cached-discovery-client-http-*") + require.NoError(t, err) + os.RemoveAll(httpCache) + defer os.RemoveAll(httpCache) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var agg *apidiscovery.APIGroupDiscoveryList + switch req.URL.Path { + case "/api": + agg = test.corev1 + case "/apis": + agg = test.apis + default: + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(agg) + if err != nil { + t.Fatalf("unexpected encoding error: %v", err) + return + } + // Content-type is "aggregated" discovery format. + w.Header().Set("Content-Type", discovery.AcceptV2Beta1) + w.WriteHeader(http.StatusOK) + w.Write(output) + })) + defer server.Close() + client, err := NewCachedDiscoveryClientForConfig( + &restclient.Config{Host: server.URL}, + discoCache, + httpCache, + 1*time.Nanosecond, + ) + require.NoError(t, err) + apiGroupList, err := client.ServerGroups() + require.NoError(t, err) + // Discovery groups cached in servergroups.json file. + numFound, err := numFilesFound(discoCache, "servergroups.json") + assert.NoError(t, err) + assert.Equal(t, 1, numFound, + "%s: expected 1 discovery cache file servergroups.json found, got %d", test.name, numFound) + // Test expected groups returned by server groups. + expectedGroupNames := sets.NewString(test.expectedGroupNames...) + actualGroupNames := sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + // Test the expected group versions for the aggregated discovery is correct. + expectedGroupVersions := sets.NewString(test.expectedGroupVersions...) + actualGroupVersions := sets.NewString(groupVersionsFromGroups(apiGroupList)...) + assert.True(t, expectedGroupVersions.Equal(actualGroupVersions), + "%s: Expected group/versions (%s), got (%s)", test.name, expectedGroupVersions.List(), actualGroupVersions.List()) + // Test the groups preferred version is correct. + expectedPreferredVersions := sets.NewString(test.expectedPreferredVersions...) + actualPreferredVersions := sets.NewString(preferredVersionsFromList(apiGroupList)...) + assert.True(t, expectedPreferredVersions.Equal(actualPreferredVersions), + "%s: Expected preferred group/version (%s), got (%s)", test.name, expectedPreferredVersions.List(), actualPreferredVersions.List()) + } +} + +func numFilesFound(dir string, filename string) (int, error) { + numFound := 0 + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Name() == filename { + numFound++ + } + return nil + }) + if err != nil { + return 0, err + } + return numFound, nil +} + type fakeDiscoveryClient struct { groupCalls int resourceCalls int @@ -306,3 +785,30 @@ func (c *fakeDiscoveryClient) OpenAPISchema() (*openapi_v2.Document, error) { func (d *fakeDiscoveryClient) OpenAPIV3() openapi.Client { panic("unimplemented") } + +func groupNamesFromList(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + result = append(result, group.Name) + } + return result +} + +func preferredVersionsFromList(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + preferredGV := group.PreferredVersion.GroupVersion + result = append(result, preferredGV) + } + return result +} + +func groupVersionsFromGroups(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + for _, version := range group.Versions { + result = append(result, version.GroupVersion) + } + } + return result +} diff --git a/discovery/cached/memory/memcache.go b/discovery/cached/memory/memcache.go index 117c66f286..9c3890180c 100644 --- a/discovery/cached/memory/memcache.go +++ b/discovery/cached/memory/memcache.go @@ -26,6 +26,7 @@ import ( errorsutil "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/discovery" @@ -47,11 +48,12 @@ type cacheEntry struct { type memCacheClient struct { delegate discovery.DiscoveryInterface - lock sync.RWMutex - groupToServerResources map[string]*cacheEntry - groupList *metav1.APIGroupList - cacheValid bool - openapiClient openapi.Client + lock sync.RWMutex + groupToServerResources map[string]*cacheEntry + groupList *metav1.APIGroupList + cacheValid bool + openapiClient openapi.Client + receivedAggregatedDiscovery bool } // Error Constants @@ -115,15 +117,39 @@ func (d *memCacheClient) ServerGroupsAndResources() ([]*metav1.APIGroup, []*meta return discovery.ServerGroupsAndResources(d) } -func (d *memCacheClient) ServerGroups() (*metav1.APIGroupList, error) { +// GroupsAndMaybeResources returns the list of APIGroups, and possibly the map of group/version +// to resources. The returned groups will never be nil, but the resources map can be nil +// if there are no cached resources. +func (d *memCacheClient) GroupsAndMaybeResources() (*metav1.APIGroupList, map[schema.GroupVersion]*metav1.APIResourceList, error) { d.lock.Lock() defer d.lock.Unlock() + if !d.cacheValid { if err := d.refreshLocked(); err != nil { - return nil, err + return nil, nil, err } } - return d.groupList, nil + // Build the resourceList from the cache? + var resourcesMap map[schema.GroupVersion]*metav1.APIResourceList + if d.receivedAggregatedDiscovery && len(d.groupToServerResources) > 0 { + resourcesMap = map[schema.GroupVersion]*metav1.APIResourceList{} + for gv, cacheEntry := range d.groupToServerResources { + groupVersion, err := schema.ParseGroupVersion(gv) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse group version (%v): %v", gv, err) + } + resourcesMap[groupVersion] = cacheEntry.resourceList + } + } + return d.groupList, resourcesMap, nil +} + +func (d *memCacheClient) ServerGroups() (*metav1.APIGroupList, error) { + groups, _, err := d.GroupsAndMaybeResources() + if err != nil { + return nil, err + } + return groups, nil } func (d *memCacheClient) RESTClient() restclient.Interface { @@ -176,6 +202,10 @@ func (d *memCacheClient) Invalidate() { d.groupToServerResources = nil d.groupList = nil d.openapiClient = nil + d.receivedAggregatedDiscovery = false + if ad, ok := d.delegate.(discovery.CachedDiscoveryInterface); ok { + ad.Invalidate() + } } // refreshLocked refreshes the state of cache. The caller must hold d.lock for @@ -184,7 +214,26 @@ func (d *memCacheClient) refreshLocked() error { // TODO: Could this multiplicative set of calls be replaced by a single call // to ServerResources? If it's possible for more than one resulting // APIResourceList to have the same GroupVersion, the lists would need merged. - gl, err := d.delegate.ServerGroups() + var gl *metav1.APIGroupList + var err error + + if ad, ok := d.delegate.(discovery.AggregatedDiscoveryInterface); ok { + var resources map[schema.GroupVersion]*metav1.APIResourceList + gl, resources, err = ad.GroupsAndMaybeResources() + if resources != nil && err == nil { + // Cache the resources. + d.groupToServerResources = map[string]*cacheEntry{} + d.groupList = gl + for gv, resources := range resources { + d.groupToServerResources[gv.String()] = &cacheEntry{resources, nil} + } + d.receivedAggregatedDiscovery = true + d.cacheValid = true + return nil + } + } else { + gl, err = d.delegate.ServerGroups() + } if err != nil || len(gl.Groups) == 0 { utilruntime.HandleError(fmt.Errorf("couldn't get current server API group list: %v", err)) return err @@ -237,7 +286,8 @@ func (d *memCacheClient) serverResourcesForGroupVersion(groupVersion string) (*m // NOTE: The client will NOT resort to live lookups on cache misses. func NewMemCacheClient(delegate discovery.DiscoveryInterface) discovery.CachedDiscoveryInterface { return &memCacheClient{ - delegate: delegate, - groupToServerResources: map[string]*cacheEntry{}, + delegate: delegate, + groupToServerResources: map[string]*cacheEntry{}, + receivedAggregatedDiscovery: false, } } diff --git a/discovery/cached/memory/memcache_test.go b/discovery/cached/memory/memcache_test.go index a3a29bf777..6be6f52164 100644 --- a/discovery/cached/memory/memcache_test.go +++ b/discovery/cached/memory/memcache_test.go @@ -17,17 +17,22 @@ limitations under the License. package memory import ( + "encoding/json" "errors" + "fmt" "net/http" + "net/http/httptest" "reflect" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + apidiscovery "k8s.io/api/apidiscovery/v2beta1" errorsutil "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/fake" "k8s.io/client-go/openapi" @@ -462,3 +467,946 @@ func TestOpenAPIMemCache(t *testing.T) { }) } } + +// Tests function "GroupsAndMaybeResources" when the "unaggregated" discovery is returned. +func TestMemCacheGroupsAndMaybeResources(t *testing.T) { + tests := []struct { + name string + corev1 *metav1.APIVersions + apis *metav1.APIGroupList + expectedGroupNames []string + expectedGroupVersions []string + }{ + { + name: "Legacy discovery format: 1 version at /api, 1 group at /apis", + corev1: &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + }, + apis: &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "extensions/v1beta1"}, + }, + }, + }, + }, + expectedGroupNames: []string{"", "extensions"}, + expectedGroupVersions: []string{"v1", "extensions/v1beta1"}, + }, + { + name: "Legacy discovery format: 1 version at /api, 2 groups/1 version at /apis", + corev1: &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + }, + apis: &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "apps", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "apps/v1"}, + }, + }, + { + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "extensions/v1beta1"}, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps", "extensions"}, + expectedGroupVersions: []string{"v1", "apps/v1", "extensions/v1beta1"}, + }, + { + name: "Legacy discovery format: 1 version at /api, 2 groups/2 versions at /apis", + corev1: &metav1.APIVersions{ + Versions: []string{ + "v1", + }, + }, + apis: &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "batch", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "batch/v1"}, + }, + }, + { + Name: "batch", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "batch/v1beta1"}, + }, + }, + { + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "extensions/v1beta1"}, + }, + }, + { + Name: "extensions", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "extensions/v1alpha1"}, + }, + }, + }, + }, + expectedGroupNames: []string{ + "", + "batch", + "extensions", + }, + expectedGroupVersions: []string{ + "v1", + "batch/v1", + "batch/v1beta1", + "extensions/v1beta1", + "extensions/v1alpha1", + }, + }, + } + + for _, test := range tests { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var body interface{} + switch req.URL.Path { + case "/api": + body = test.corev1 + case "/apis": + body = test.apis + default: + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(body) + require.NoError(t, err) + // Content-type is "unaggregated" discovery format -- no resources returned. + w.Header().Set("Content-Type", discovery.AcceptV1) + w.WriteHeader(http.StatusOK) + w.Write(output) + })) + defer server.Close() + client := discovery.NewDiscoveryClientForConfigOrDie(&rest.Config{Host: server.URL}) + memClient := memCacheClient{ + delegate: client, + groupToServerResources: map[string]*cacheEntry{}, + } + assert.False(t, memClient.Fresh()) + apiGroupList, resourcesMap, err := memClient.GroupsAndMaybeResources() + require.NoError(t, err) + // "Unaggregated" discovery always returns nil for resources. + assert.Nil(t, resourcesMap) + assert.False(t, memClient.receivedAggregatedDiscovery) + assert.True(t, memClient.Fresh()) + // Test the expected groups are returned for the aggregated format. + expectedGroupNames := sets.NewString(test.expectedGroupNames...) + actualGroupNames := sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + // Test the expected group versions for the aggregated discovery is correct. + expectedGroupVersions := sets.NewString(test.expectedGroupVersions...) + actualGroupVersions := sets.NewString(groupVersionsFromGroups(apiGroupList)...) + assert.True(t, expectedGroupVersions.Equal(actualGroupVersions), + "%s: Expected group/versions (%s), got (%s)", test.name, expectedGroupVersions.List(), actualGroupVersions.List()) + // Invalidate the cache and retrieve the server groups and resources again. + memClient.Invalidate() + assert.False(t, memClient.Fresh()) + apiGroupList, resourcesMap, err = memClient.GroupsAndMaybeResources() + require.NoError(t, err) + assert.Nil(t, resourcesMap) + assert.False(t, memClient.receivedAggregatedDiscovery) + // Test the expected groups are returned for the aggregated format. + actualGroupNames = sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected after invalidation groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + } +} + +// Tests function "GroupsAndMaybeResources" when the "aggregated" discovery is returned. +func TestAggregatedMemCacheGroupsAndMaybeResources(t *testing.T) { + tests := []struct { + name string + corev1 *apidiscovery.APIGroupDiscoveryList + apis *apidiscovery.APIGroupDiscoveryList + expectedGroupNames []string + expectedGroupVersions []string + expectedGVKs []string + }{ + { + name: "Aggregated discovery: 1 group/1 resources at /api, 1 group/1 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1"}, + expectedGVKs: []string{ + "/v1/Pod", + "apps/v1/Deployment", + }, + }, + { + name: "Aggregated discovery: 1 group/1 resources at /api, 1 group/2 versions/1 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v2", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v2", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1", "apps/v2"}, + expectedGVKs: []string{ + "/v1/Pod", + "apps/v1/Deployment", + "apps/v2/Deployment", + }, + }, + { + name: "Aggregated discovery: 1 group/2 resources at /api, 1 group/2 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "services", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1"}, + expectedGVKs: []string{ + "/v1/Pod", + "/v1/Service", + "apps/v1/Deployment", + "apps/v1/StatefulSet", + }, + }, + { + name: "Aggregated discovery: 1 group/2 resources at /api, 2 group/2 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "services", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps", "batch"}, + expectedGroupVersions: []string{"v1", "apps/v1", "batch/v1"}, + expectedGVKs: []string{ + "/v1/Pod", + "/v1/Service", + "apps/v1/Deployment", + "apps/v1/StatefulSet", + "batch/v1/Job", + "batch/v1/CronJob", + }, + }, + { + name: "Aggregated discovery: /api returns nothing, 2 groups/2 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{}, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"apps", "batch"}, + expectedGroupVersions: []string{"apps/v1", "batch/v1"}, + expectedGVKs: []string{ + "apps/v1/Deployment", + "apps/v1/StatefulSet", + "batch/v1/Job", + "batch/v1/CronJob", + }, + }, + } + + for _, test := range tests { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var agg *apidiscovery.APIGroupDiscoveryList + switch req.URL.Path { + case "/api": + agg = test.corev1 + case "/apis": + agg = test.apis + default: + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(agg) + require.NoError(t, err) + // Content-type is "aggregated" discovery format. + w.Header().Set("Content-Type", discovery.AcceptV2Beta1) + w.WriteHeader(http.StatusOK) + w.Write(output) + })) + defer server.Close() + client := discovery.NewDiscoveryClientForConfigOrDie(&rest.Config{Host: server.URL}) + memClient := memCacheClient{ + delegate: client, + groupToServerResources: map[string]*cacheEntry{}, + } + assert.False(t, memClient.Fresh()) + apiGroupList, resourcesMap, err := memClient.GroupsAndMaybeResources() + require.NoError(t, err) + assert.True(t, memClient.receivedAggregatedDiscovery) + assert.True(t, memClient.Fresh()) + // Test the expected groups are returned for the aggregated format. + expectedGroupNames := sets.NewString(test.expectedGroupNames...) + actualGroupNames := sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + // Test the expected group versions for the aggregated discovery is correct. + expectedGroupVersions := sets.NewString(test.expectedGroupVersions...) + actualGroupVersions := sets.NewString(groupVersionsFromGroups(apiGroupList)...) + assert.True(t, expectedGroupVersions.Equal(actualGroupVersions), + "%s: Expected group/versions (%s), got (%s)", test.name, expectedGroupVersions.List(), actualGroupVersions.List()) + // Test the resources are correct. + expectedGVKs := sets.NewString(test.expectedGVKs...) + resources := []*metav1.APIResourceList{} + for _, resourceList := range resourcesMap { + resources = append(resources, resourceList) + } + actualGVKs := sets.NewString(groupVersionKinds(resources)...) + assert.True(t, expectedGVKs.Equal(actualGVKs), + "%s: Expected GVKs (%s), got (%s)", test.name, expectedGVKs.List(), actualGVKs.List()) + // Invalidate the cache and retrieve the server groups again. + memClient.Invalidate() + assert.False(t, memClient.Fresh()) + apiGroupList, _, err = memClient.GroupsAndMaybeResources() + require.NoError(t, err) + // Test the expected groups are returned for the aggregated format. + actualGroupNames = sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected after invalidation groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + } +} + +// Tests function "ServerGroups" when the "aggregated" discovery is returned. +func TestMemCacheAggregatedServerGroups(t *testing.T) { + tests := []struct { + name string + corev1 *apidiscovery.APIGroupDiscoveryList + apis *apidiscovery.APIGroupDiscoveryList + expectedGroupNames []string + expectedGroupVersions []string + expectedPreferredVersions []string + }{ + { + name: "Aggregated discovery: 1 group/1 version at /api, 1 group/1 version at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1"}, + expectedPreferredVersions: []string{"v1", "apps/v1"}, + }, + { + name: "Aggregated discovery: 1 group/1 version at /api, 1 group/2 versions at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + // v2 is preferred since it is first + { + Version: "v2", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v2", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1", "apps/v2"}, + expectedPreferredVersions: []string{"v1", "apps/v2"}, + }, + { + name: "Aggregated discovery: /api returns nothing, 2 groups at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{}, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + // v1 is preferred since it is first + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1beta1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1beta1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1beta1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"apps", "batch"}, + expectedGroupVersions: []string{"apps/v1", "batch/v1", "batch/v1beta1"}, + expectedPreferredVersions: []string{"apps/v1", "batch/v1"}, + }, + } + + for _, test := range tests { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var agg *apidiscovery.APIGroupDiscoveryList + switch req.URL.Path { + case "/api": + agg = test.corev1 + case "/apis": + agg = test.apis + default: + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(agg) + require.NoError(t, err) + // Content-type is "aggregated" discovery format. + w.Header().Set("Content-Type", discovery.AcceptV2Beta1) + w.WriteHeader(http.StatusOK) + w.Write(output) + })) + defer server.Close() + client := discovery.NewDiscoveryClientForConfigOrDie(&rest.Config{Host: server.URL}) + memCacheClient := NewMemCacheClient(client) + assert.False(t, memCacheClient.Fresh()) + apiGroupList, err := memCacheClient.ServerGroups() + require.NoError(t, err) + assert.True(t, memCacheClient.Fresh()) + // Test the expected groups are returned for the aggregated format. + expectedGroupNames := sets.NewString(test.expectedGroupNames...) + actualGroupNames := sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + // Test the expected group versions for the aggregated discovery is correct. + expectedGroupVersions := sets.NewString(test.expectedGroupVersions...) + actualGroupVersions := sets.NewString(groupVersionsFromGroups(apiGroupList)...) + assert.True(t, expectedGroupVersions.Equal(actualGroupVersions), + "%s: Expected group/versions (%s), got (%s)", test.name, expectedGroupVersions.List(), actualGroupVersions.List()) + // Test the groups preferred version is correct. + expectedPreferredVersions := sets.NewString(test.expectedPreferredVersions...) + actualPreferredVersions := sets.NewString(preferredVersionsFromList(apiGroupList)...) + assert.True(t, expectedPreferredVersions.Equal(actualPreferredVersions), + "%s: Expected preferred group/version (%s), got (%s)", test.name, expectedPreferredVersions.List(), actualPreferredVersions.List()) + // Invalidate the cache and retrieve the server groups again. + memCacheClient.Invalidate() + assert.False(t, memCacheClient.Fresh()) + apiGroupList, err = memCacheClient.ServerGroups() + require.NoError(t, err) + // Test the expected groups are returned for the aggregated format. + actualGroupNames = sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected after invalidation groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + } +} + +func groupNamesFromList(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + result = append(result, group.Name) + } + return result +} + +func preferredVersionsFromList(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + preferredGV := group.PreferredVersion.GroupVersion + result = append(result, preferredGV) + } + return result +} + +func groupVersionsFromGroups(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + for _, version := range group.Versions { + result = append(result, version.GroupVersion) + } + } + return result +} + +func groupVersionKinds(resources []*metav1.APIResourceList) []string { + result := []string{} + for _, resourceList := range resources { + for _, resource := range resourceList.APIResources { + gvk := fmt.Sprintf("%s/%s/%s", resource.Group, resource.Version, resource.Kind) + result = append(result, gvk) + } + } + return result +} diff --git a/discovery/discovery_client.go b/discovery/discovery_client.go index ec7ebf0510..f55cef3eac 100644 --- a/discovery/discovery_client.go +++ b/discovery/discovery_client.go @@ -31,6 +31,7 @@ import ( "github.com/golang/protobuf/proto" openapi_v2 "github.com/google/gnostic/openapiv2" + apidiscovery "k8s.io/api/apidiscovery/v2beta1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -55,6 +56,13 @@ const ( // defaultBurst is the default burst to be used with the discovery client's token bucket rate limiter defaultBurst = 300 + + AcceptV1 = runtime.ContentTypeJSON + // Aggregated discovery content-type (currently v2beta1). NOTE: Currently, we are assuming the order + // for "g", "v", and "as" from the server. We can only compare this string if we can make that assumption. + AcceptV2Beta1 = runtime.ContentTypeJSON + ";" + "g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList" + // Prioritize aggregated discovery by placing first in the order of discovery accept types. + acceptDiscoveryFormats = AcceptV2Beta1 + "," + AcceptV1 ) // DiscoveryInterface holds the methods that discover server-supported API groups, @@ -68,6 +76,15 @@ type DiscoveryInterface interface { OpenAPIV3SchemaInterface } +// AggregatedDiscoveryInterface extends DiscoveryInterface to include a method to possibly +// return discovery resources along with the discovery groups, which is what the newer +// aggregated discovery format does (APIGroupDiscoveryList). +type AggregatedDiscoveryInterface interface { + DiscoveryInterface + + GroupsAndMaybeResources() (*metav1.APIGroupList, map[schema.GroupVersion]*metav1.APIResourceList, error) +} + // CachedDiscoveryInterface is a DiscoveryInterface with cache invalidation and freshness. // Note that If the ServerResourcesForGroupVersion method returns a cache miss // error, the user needs to explicitly call Invalidate to clear the cache, @@ -139,6 +156,8 @@ type DiscoveryClient struct { LegacyPrefix string } +var _ AggregatedDiscoveryInterface = &DiscoveryClient{} + // Convert metav1.APIVersions to metav1.APIGroup. APIVersions is used by legacy v1, so // group would be "". func apiVersionsToAPIGroup(apiVersions *metav1.APIVersions) (apiGroup metav1.APIGroup) { @@ -156,36 +175,140 @@ func apiVersionsToAPIGroup(apiVersions *metav1.APIVersions) (apiGroup metav1.API return } -// ServerGroups returns the supported groups, with information like supported versions and the -// preferred version. -func (d *DiscoveryClient) ServerGroups() (apiGroupList *metav1.APIGroupList, err error) { - // Get the groupVersions exposed at /api - v := &metav1.APIVersions{} - err = d.restClient.Get().AbsPath(d.LegacyPrefix).Do(context.TODO()).Into(v) - apiGroup := metav1.APIGroup{} - if err == nil && len(v.Versions) != 0 { - apiGroup = apiVersionsToAPIGroup(v) +// GroupsAndMaybeResources returns the discovery groups, and (if new aggregated +// discovery format) the resources keyed by group/version. Merges discovery groups +// and resources from /api and /apis (either aggregated or not). Legacy groups +// must be ordered first. The server will either return both endpoints (/api, /apis) +// as aggregated discovery format or legacy format. For safety, resources will only +// be returned if both endpoints returned resources. +func (d *DiscoveryClient) GroupsAndMaybeResources() (*metav1.APIGroupList, map[schema.GroupVersion]*metav1.APIResourceList, error) { + // Legacy group ordered first (there is only one -- core/v1 group). Returned groups must + // be non-nil, but it could be empty. Returned resources, apiResources map could be nil. + groups, resources, err := d.downloadLegacy() + if err != nil { + return nil, nil, err + } + // Discovery groups and (possibly) resources downloaded from /apis. + apiGroups, apiResources, aerr := d.downloadAPIs() + if err != nil { + return nil, nil, aerr + } + // Merge apis groups into the legacy groups. + for _, group := range apiGroups.Groups { + groups.Groups = append(groups.Groups, group) + } + // For safety, only return resources if both endpoints returned resources. + if resources != nil && apiResources != nil { + for gv, resourceList := range apiResources { + resources[gv] = resourceList + } + } else if resources != nil { + resources = nil } + return groups, resources, err +} + +// downloadLegacy returns the discovery groups and possibly resources +// for the legacy v1 GVR at /api, or an error if one occurred. It is +// possible for the resource map to be nil if the server returned +// the unaggregated discovery. +func (d *DiscoveryClient) downloadLegacy() (*metav1.APIGroupList, map[schema.GroupVersion]*metav1.APIResourceList, error) { + var responseContentType string + body, err := d.restClient.Get(). + AbsPath("/api"). + SetHeader("Accept", acceptDiscoveryFormats). + Do(context.TODO()). + ContentType(&responseContentType). + Raw() + // Special error handling for 403 or 404 to be compatible with older v1.0 servers. + // Return empty group list to be merged with /apis. if err != nil && !errors.IsNotFound(err) && !errors.IsForbidden(err) { - return nil, err + return nil, nil, err + } + if err != nil && (errors.IsNotFound(err) || errors.IsForbidden(err)) { + return &metav1.APIGroupList{}, nil, nil + } + + apiGroupList := &metav1.APIGroupList{} + var resourcesByGV map[schema.GroupVersion]*metav1.APIResourceList + // Switch on content-type server responded with: aggregated or unaggregated. + switch responseContentType { + case AcceptV1: + var v metav1.APIVersions + err = json.Unmarshal(body, &v) + if err != nil { + return nil, nil, err + } + apiGroup := metav1.APIGroup{} + if len(v.Versions) != 0 { + apiGroup = apiVersionsToAPIGroup(&v) + } + apiGroupList.Groups = []metav1.APIGroup{apiGroup} + case AcceptV2Beta1: + var aggregatedDiscovery apidiscovery.APIGroupDiscoveryList + err = json.Unmarshal(body, &aggregatedDiscovery) + if err != nil { + return nil, nil, err + } + apiGroupList, resourcesByGV = SplitGroupsAndResources(aggregatedDiscovery) + default: + return nil, nil, fmt.Errorf("Unknown discovery response content-type: %s", responseContentType) } - // Get the groupVersions exposed at /apis - apiGroupList = &metav1.APIGroupList{} - err = d.restClient.Get().AbsPath("/apis").Do(context.TODO()).Into(apiGroupList) + return apiGroupList, resourcesByGV, nil +} + +// downloadAPIs returns the discovery groups and (if aggregated format) the +// discovery resources. The returned groups will always exist, but the +// resources map may be nil. +func (d *DiscoveryClient) downloadAPIs() (*metav1.APIGroupList, map[schema.GroupVersion]*metav1.APIResourceList, error) { + var responseContentType string + body, err := d.restClient.Get(). + AbsPath("/apis"). + SetHeader("Accept", acceptDiscoveryFormats). + Do(context.TODO()). + ContentType(&responseContentType). + Raw() + // Special error handling for 403 or 404 to be compatible with older v1.0 servers. + // Return empty group list to be merged with /api. if err != nil && !errors.IsNotFound(err) && !errors.IsForbidden(err) { - return nil, err + return nil, nil, err } - // to be compatible with a v1.0 server, if it's a 403 or 404, ignore and return whatever we got from /api if err != nil && (errors.IsNotFound(err) || errors.IsForbidden(err)) { - apiGroupList = &metav1.APIGroupList{} + return &metav1.APIGroupList{}, nil, nil + } + + apiGroupList := &metav1.APIGroupList{} + var resourcesByGV map[schema.GroupVersion]*metav1.APIResourceList + // Switch on content-type server responded with: aggregated or unaggregated. + switch responseContentType { + case AcceptV1: + err = json.Unmarshal(body, apiGroupList) + if err != nil { + return nil, nil, err + } + case AcceptV2Beta1: + var aggregatedDiscovery apidiscovery.APIGroupDiscoveryList + err = json.Unmarshal(body, &aggregatedDiscovery) + if err != nil { + return nil, nil, err + } + apiGroupList, resourcesByGV = SplitGroupsAndResources(aggregatedDiscovery) + default: + return nil, nil, fmt.Errorf("Unknown discovery response content-type: %s", responseContentType) } - // prepend the group retrieved from /api to the list if not empty - if len(v.Versions) != 0 { - apiGroupList.Groups = append([]metav1.APIGroup{apiGroup}, apiGroupList.Groups...) + return apiGroupList, resourcesByGV, nil +} + +// ServerGroups returns the supported groups, with information like supported versions and the +// preferred version. +func (d *DiscoveryClient) ServerGroups() (*metav1.APIGroupList, error) { + groups, _, err := d.GroupsAndMaybeResources() + if err != nil { + return nil, err } - return apiGroupList, nil + return groups, nil } // ServerResourcesForGroupVersion returns the supported resources for a group and version. @@ -244,7 +367,22 @@ func IsGroupDiscoveryFailedError(err error) bool { } func ServerGroupsAndResources(d DiscoveryInterface) ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { - sgs, err := d.ServerGroups() + var sgs *metav1.APIGroupList + var resources []*metav1.APIResourceList + var err error + + // If the passed discovery object implements the wider AggregatedDiscoveryInterface, + // then attempt to retrieve aggregated discovery with both groups and the resources. + if ad, ok := d.(AggregatedDiscoveryInterface); ok { + var resourcesByGV map[schema.GroupVersion]*metav1.APIResourceList + sgs, resourcesByGV, err = ad.GroupsAndMaybeResources() + for _, resourceList := range resourcesByGV { + resources = append(resources, resourceList) + } + } else { + sgs, err = d.ServerGroups() + } + if sgs == nil { return nil, nil, err } @@ -252,6 +390,9 @@ func ServerGroupsAndResources(d DiscoveryInterface) ([]*metav1.APIGroup, []*meta for i := range sgs.Groups { resultGroups = append(resultGroups, &sgs.Groups[i]) } + if resources != nil { + return resultGroups, resources, nil + } groupVersionResources, failedGroups := fetchGroupVersionResources(d, sgs) @@ -275,12 +416,25 @@ func ServerGroupsAndResources(d DiscoveryInterface) ([]*metav1.APIGroup, []*meta // ServerPreferredResources uses the provided discovery interface to look up preferred resources func ServerPreferredResources(d DiscoveryInterface) ([]*metav1.APIResourceList, error) { - serverGroupList, err := d.ServerGroups() + var serverGroupList *metav1.APIGroupList + var failedGroups map[schema.GroupVersion]error + var groupVersionResources map[schema.GroupVersion]*metav1.APIResourceList + var err error + + // If the passed discovery object implements the wider AggregatedDiscoveryInterface, + // then it is attempt to retrieve both the groups and the resources. + ad, ok := d.(AggregatedDiscoveryInterface) + if ok { + serverGroupList, groupVersionResources, err = ad.GroupsAndMaybeResources() + } else { + serverGroupList, err = d.ServerGroups() + } if err != nil { return nil, err } - - groupVersionResources, failedGroups := fetchGroupVersionResources(d, serverGroupList) + if groupVersionResources == nil { + groupVersionResources, failedGroups = fetchGroupVersionResources(d, serverGroupList) + } result := []*metav1.APIResourceList{} grVersions := map[schema.GroupResource]string{} // selected version of a GroupResource diff --git a/discovery/discovery_client_test.go b/discovery/discovery_client_test.go index 47e41970b4..0610586891 100644 --- a/discovery/discovery_client_test.go +++ b/discovery/discovery_client_test.go @@ -33,6 +33,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" golangproto "google.golang.org/protobuf/proto" + apidiscovery "k8s.io/api/apidiscovery/v2beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -1103,6 +1104,1224 @@ func TestServerPreferredNamespacedResources(t *testing.T) { } } +// Tests of the aggregated discovery format. +func TestAggregatedServerGroups(t *testing.T) { + tests := []struct { + name string + corev1 *apidiscovery.APIGroupDiscoveryList + apis *apidiscovery.APIGroupDiscoveryList + expectedGroupNames []string + expectedGroupVersions []string + expectedPreferredVersions []string + }{ + { + name: "Aggregated discovery: 1 group/1 version at /api, 1 group/1 version at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1"}, + expectedPreferredVersions: []string{"v1", "apps/v1"}, + }, + { + name: "Aggregated discovery: 1 group/1 version at /api, 1 group/2 versions at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + // v2 is preferred since it is first + { + Version: "v2", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v2", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1", "apps/v2"}, + expectedPreferredVersions: []string{"v1", "apps/v2"}, + }, + { + name: "Aggregated discovery: /api returns nothing, 2 groups at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{}, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + // v1 is preferred since it is first + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1beta1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1beta1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1beta1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"apps", "batch"}, + expectedGroupVersions: []string{"apps/v1", "batch/v1", "batch/v1beta1"}, + expectedPreferredVersions: []string{"apps/v1", "batch/v1"}, + }, + } + + for _, test := range tests { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var agg *apidiscovery.APIGroupDiscoveryList + switch req.URL.Path { + case "/api": + agg = test.corev1 + case "/apis": + agg = test.apis + default: + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(agg) + require.NoError(t, err) + // Content-type is "aggregated" discovery format. + w.Header().Set("Content-Type", AcceptV2Beta1) + w.WriteHeader(http.StatusOK) + w.Write(output) + })) + defer server.Close() + client := NewDiscoveryClientForConfigOrDie(&restclient.Config{Host: server.URL}) + apiGroupList, err := client.ServerGroups() + require.NoError(t, err) + // Test the expected groups are returned for the aggregated format. + expectedGroupNames := sets.NewString(test.expectedGroupNames...) + actualGroupNames := sets.NewString(groupNamesFromList(apiGroupList)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected groups (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + // Test the expected group versions for the aggregated discovery is correct. + expectedGroupVersions := sets.NewString(test.expectedGroupVersions...) + actualGroupVersions := sets.NewString(groupVersionsFromGroups(apiGroupList)...) + assert.True(t, expectedGroupVersions.Equal(actualGroupVersions), + "%s: Expected group/versions (%s), got (%s)", test.name, expectedGroupVersions.List(), actualGroupVersions.List()) + // Test the groups preferred version is correct. + expectedPreferredVersions := sets.NewString(test.expectedPreferredVersions...) + actualPreferredVersions := sets.NewString(preferredVersionsFromList(apiGroupList)...) + assert.True(t, expectedPreferredVersions.Equal(actualPreferredVersions), + "%s: Expected preferred group/version (%s), got (%s)", test.name, expectedPreferredVersions.List(), actualPreferredVersions.List()) + } +} + +func TestAggregatedServerGroupsAndResources(t *testing.T) { + tests := []struct { + name string + corev1 *apidiscovery.APIGroupDiscoveryList + apis *apidiscovery.APIGroupDiscoveryList + expectedGroupNames []string + expectedGroupVersions []string + expectedGVKs []string + }{ + { + name: "Aggregated discovery: 1 group/1 resources at /api, 1 group/1 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1"}, + expectedGVKs: []string{ + "/v1/Pod", + "apps/v1/Deployment", + }, + }, + { + name: "Aggregated discovery: 1 group/1 resources at /api, 1 group/2 versions/1 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v2", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v2", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1", "apps/v2"}, + expectedGVKs: []string{ + "/v1/Pod", + "apps/v1/Deployment", + "apps/v2/Deployment", + }, + }, + { + name: "Aggregated discovery: 1 group/2 resources at /api, 1 group/2 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "services", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps"}, + expectedGroupVersions: []string{"v1", "apps/v1"}, + expectedGVKs: []string{ + "/v1/Pod", + "/v1/Service", + "apps/v1/Deployment", + "apps/v1/StatefulSet", + }, + }, + { + name: "Aggregated discovery: 1 group/2 resources at /api, 2 group/2 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "services", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"", "apps", "batch"}, + expectedGroupVersions: []string{"v1", "apps/v1", "batch/v1"}, + expectedGVKs: []string{ + "/v1/Pod", + "/v1/Service", + "apps/v1/Deployment", + "apps/v1/StatefulSet", + "batch/v1/Job", + "batch/v1/CronJob", + }, + }, + { + name: "Aggregated discovery: /api returns nothing, 2 groups/2 resources at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{}, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGroupNames: []string{"apps", "batch"}, + expectedGroupVersions: []string{"apps/v1", "batch/v1"}, + expectedGVKs: []string{ + "apps/v1/Deployment", + "apps/v1/StatefulSet", + "batch/v1/Job", + "batch/v1/CronJob", + }, + }, + } + + for _, test := range tests { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var agg *apidiscovery.APIGroupDiscoveryList + switch req.URL.Path { + case "/api": + agg = test.corev1 + case "/apis": + agg = test.apis + default: + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(agg) + require.NoError(t, err) + // Content-type is "aggregated" discovery format. + w.Header().Set("Content-Type", AcceptV2Beta1) + w.WriteHeader(http.StatusOK) + w.Write(output) + })) + defer server.Close() + client := NewDiscoveryClientForConfigOrDie(&restclient.Config{Host: server.URL}) + apiGroups, resources, err := client.ServerGroupsAndResources() + require.NoError(t, err) + // Test the expected groups are returned for the aggregated format. + expectedGroupNames := sets.NewString(test.expectedGroupNames...) + actualGroupNames := sets.NewString(groupNames(apiGroups)...) + assert.True(t, expectedGroupNames.Equal(actualGroupNames), + "%s: Expected GVKs (%s), got (%s)", test.name, expectedGroupNames.List(), actualGroupNames.List()) + // If the core V1 group is returned from /api, it should be the first group. + if expectedGroupNames.Has("") { + assert.True(t, len(apiGroups) > 0) + actualFirstGroup := apiGroups[0] + assert.True(t, len(actualFirstGroup.Versions) > 0) + actualFirstGroupVersion := actualFirstGroup.Versions[0].GroupVersion + assert.Equal(t, "v1", actualFirstGroupVersion) + } + // Test the expected group/versions are returned from the aggregated discovery. + expectedGroupVersions := sets.NewString(test.expectedGroupVersions...) + actualGroupVersions := sets.NewString(groupVersions(resources)...) + assert.True(t, expectedGroupVersions.Equal(actualGroupVersions), + "%s: Expected GroupVersions(%s), got (%s)", test.name, expectedGroupVersions.List(), actualGroupVersions.List()) + // Test the expected GVKs are returned from the aggregated discovery. + expectedGVKs := sets.NewString(test.expectedGVKs...) + actualGVKs := sets.NewString(groupVersionKinds(resources)...) + assert.True(t, expectedGVKs.Equal(actualGVKs), + "%s: Expected GVKs (%s), got (%s)", test.name, expectedGVKs.List(), actualGVKs.List()) + } +} + +func TestAggregatedServerPreferredResources(t *testing.T) { + tests := []struct { + name string + corev1 *apidiscovery.APIGroupDiscoveryList + apis *apidiscovery.APIGroupDiscoveryList + expectedGVKs []string + }{ + { + name: "Aggregated discovery: basic corev1 and apps/v1 preferred resources returned", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGVKs: []string{ + "/v1/Pod", + "apps/v1/Deployment", + }, + }, + { + name: "Aggregated discovery: only resources from preferred apps/v2 group/version", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + // v2 is "preferred version since it is first + { + Version: "v2", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v2", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + // Only v2 resources from apps group, since v2 is preferred version. + expectedGVKs: []string{ + "/v1/Pod", + "apps/v2/Deployment", + }, + }, + { + name: "Aggregated discovery: preferred multiple resources from multiple group/versions", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "services", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGVKs: []string{ + "/v1/Pod", + "/v1/Service", + "apps/v1/Deployment", + "apps/v1/StatefulSet", + }, + }, + { + name: "Aggregated discovery: resources from multiple preferred group versions at /apis", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "services", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + expectedGVKs: []string{ + "/v1/Pod", + "/v1/Service", + "apps/v1/Deployment", + "apps/v1/StatefulSet", + "batch/v1/Job", + "batch/v1/CronJob", + }, + }, + { + name: "Aggregated discovery: resources from only preferred group versions for batch group", + corev1: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "pods", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "services", + ResponseKind: &metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Service", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + apis: &apidiscovery.APIGroupDiscoveryList{ + Items: []apidiscovery.APIGroupDiscovery{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "apps", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "deployments", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "statefulsets", + ResponseKind: &metav1.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "StatefulSet", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "batch", + }, + Versions: []apidiscovery.APIVersionDiscovery{ + { + Version: "v1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + { + Version: "v1beta1", + Resources: []apidiscovery.APIResourceDiscovery{ + { + Resource: "jobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1beta1", + Kind: "Job", + }, + Scope: apidiscovery.ScopeNamespace, + }, + { + Resource: "cronjobs", + ResponseKind: &metav1.GroupVersionKind{ + Group: "batch", + Version: "v1beta1", + Kind: "CronJob", + }, + Scope: apidiscovery.ScopeNamespace, + }, + }, + }, + }, + }, + }, + }, + // Only preferred resources expected--not batch/v1beta1 resources. + expectedGVKs: []string{ + "/v1/Pod", + "/v1/Service", + "apps/v1/Deployment", + "apps/v1/StatefulSet", + "batch/v1/Job", + "batch/v1/CronJob", + }, + }, + } + + for _, test := range tests { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var agg *apidiscovery.APIGroupDiscoveryList + switch req.URL.Path { + case "/api": + agg = test.corev1 + case "/apis": + agg = test.apis + default: + w.WriteHeader(http.StatusNotFound) + return + } + output, err := json.Marshal(agg) + require.NoError(t, err) + // Content-type is "aggregated" discovery format. + w.Header().Set("Content-Type", AcceptV2Beta1) + w.WriteHeader(http.StatusOK) + w.Write(output) + })) + defer server.Close() + client := NewDiscoveryClientForConfigOrDie(&restclient.Config{Host: server.URL}) + resources, err := client.ServerPreferredResources() + require.NoError(t, err) + // Test the expected preferred GVKs are returned from the aggregated discovery. + expectedGVKs := sets.NewString(test.expectedGVKs...) + actualGVKs := sets.NewString(groupVersionKinds(resources)...) + assert.True(t, expectedGVKs.Equal(actualGVKs), + "%s: Expected GVKs (%s), got (%s)", test.name, expectedGVKs.List(), actualGVKs.List()) + } +} + +func groupNames(groups []*metav1.APIGroup) []string { + result := []string{} + for _, group := range groups { + result = append(result, group.Name) + } + return result +} + +func groupNamesFromList(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + result = append(result, group.Name) + } + return result +} + +func preferredVersionsFromList(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + preferredGV := group.PreferredVersion.GroupVersion + result = append(result, preferredGV) + } + return result +} + func groupVersions(resources []*metav1.APIResourceList) []string { result := []string{} for _, resourceList := range resources { @@ -1110,3 +2329,24 @@ func groupVersions(resources []*metav1.APIResourceList) []string { } return result } + +func groupVersionsFromGroups(groups *metav1.APIGroupList) []string { + result := []string{} + for _, group := range groups.Groups { + for _, version := range group.Versions { + result = append(result, version.GroupVersion) + } + } + return result +} + +func groupVersionKinds(resources []*metav1.APIResourceList) []string { + result := []string{} + for _, resourceList := range resources { + for _, resource := range resourceList.APIResources { + gvk := fmt.Sprintf("%s/%s/%s", resource.Group, resource.Version, resource.Kind) + result = append(result, gvk) + } + } + return result +}