From d6ce6aaf447484baa73ece7181d8e38f4478364d Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu, 15 Aug 2024 04:39:53 -0400 Subject: [PATCH] Aggregation: Support apidiscovery.k8s.io/v2 (#91938) --- pkg/aggregator/apiserver/apiserver.go | 6 +- pkg/aggregator/apiserver/handler_apis.go | 143 ++++++++--- pkg/aggregator/apiserver/handler_apis_test.go | 233 ++++++++++++++++++ pkg/aggregator/go.mod | 2 +- 4 files changed, 342 insertions(+), 42 deletions(-) create mode 100644 pkg/aggregator/apiserver/handler_apis_test.go diff --git a/pkg/aggregator/apiserver/apiserver.go b/pkg/aggregator/apiserver/apiserver.go index cb144c09ab3..32e25a4885f 100644 --- a/pkg/aggregator/apiserver/apiserver.go +++ b/pkg/aggregator/apiserver/apiserver.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/aggregator/apis/aggregation/v0alpha1" v0alpha1helper "github.com/grafana/grafana/pkg/aggregator/apis/aggregation/v0alpha1/helper" - "github.com/grafana/grafana/pkg/aggregator/apiserver/scheme" clientset "github.com/grafana/grafana/pkg/aggregator/generated/clientset/versioned" informers "github.com/grafana/grafana/pkg/aggregator/generated/informers/externalversions" dataplaneservicerest "github.com/grafana/grafana/pkg/aggregator/registry/dataplaneservice/rest" @@ -83,10 +82,7 @@ func (c completedConfig) NewWithDelegate(delegationTarget genericapiserver.Deleg return nil, err } - discoveryHandler := &apisProxyHandler{ - delegationTarget: delegationTarget, - codecs: scheme.Codecs, - } + discoveryHandler := newApisProxyHandler(delegationTarget.UnprotectedHandler()) genericServer.Handler.GoRestfulContainer.Filter(discoveryHandler.handle) dataplaneServiceRegistrationControllerInitiated := make(chan struct{}) diff --git a/pkg/aggregator/apiserver/handler_apis.go b/pkg/aggregator/apiserver/handler_apis.go index 11c7570decd..c6e3448d9a2 100644 --- a/pkg/aggregator/apiserver/handler_apis.go +++ b/pkg/aggregator/apiserver/handler_apis.go @@ -6,36 +6,44 @@ import ( "net/http/httptest" "github.com/emicklei/go-restful/v3" + apidiscoveryv2 "k8s.io/api/apidiscovery/v2" + apidiscoveryv2beta1 "k8s.io/api/apidiscovery/v2beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apiserver/pkg/endpoints/discovery/aggregated" "k8s.io/apiserver/pkg/endpoints/handlers/negotiation" "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters" - genericapiserver "k8s.io/apiserver/pkg/server" - - aggregationv0alpha1api "github.com/grafana/grafana/pkg/aggregator/apis/aggregation/v0alpha1" -) - -var ( - discoveryGroup = metav1.APIGroup{ - Name: aggregationv0alpha1api.SchemeGroupVersion.Group, - Versions: []metav1.GroupVersionForDiscovery{ - { - GroupVersion: aggregationv0alpha1api.SchemeGroupVersion.String(), - Version: aggregationv0alpha1api.SchemeGroupVersion.Version, - }, - }, - PreferredVersion: metav1.GroupVersionForDiscovery{ - GroupVersion: aggregationv0alpha1api.SchemeGroupVersion.String(), - Version: aggregationv0alpha1api.SchemeGroupVersion.Version, - }, - } ) // apisProxyHandler serves the `/apis` endpoint. type apisProxyHandler struct { - delegationTarget genericapiserver.DelegationTarget - codecs serializer.CodecFactory + delegate http.Handler + codecs serializer.CodecFactory +} + +func newApisProxyHandler(delegate http.Handler) *apisProxyHandler { + scheme := runtime.NewScheme() + metav1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + + // TODO: keep the generic API server from wanting this + unversioned := schema.GroupVersion{Group: "", Version: "v1"} + scheme.AddUnversionedTypes(unversioned, + &metav1.Status{}, + &metav1.APIVersions{}, + &metav1.APIGroupList{}, + &metav1.APIGroup{}, + &metav1.APIResourceList{}, + ) + utilruntime.Must(apidiscoveryv2.AddToScheme(scheme)) + utilruntime.Must(apidiscoveryv2beta1.AddToScheme(scheme)) + codecs := serializer.NewCodecFactory(scheme) + return &apisProxyHandler{ + delegate: delegate, + codecs: codecs, + } } func (a *apisProxyHandler) handle(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) { @@ -43,26 +51,89 @@ func (a *apisProxyHandler) handle(req *restful.Request, resp *restful.Response, chain.ProcessFilter(req, resp) return } + apisHandlerWithAggregationSupport := aggregated.WrapAggregatedDiscoveryToHandler(a.v1handler(chain), a.v2handler(chain)) + apisHandlerWithAggregationSupport.ServeHTTP(resp.ResponseWriter, req.Request) +} - discoveryGroupList := &metav1.APIGroupList{ - Groups: []metav1.APIGroup{discoveryGroup}, +func (a *apisProxyHandler) v2handler(chain *restful.FilterChain) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + clonedReq := req.Clone(req.Context()) + newReq := restful.NewRequest(clonedReq) + rw := httptest.NewRecorder() + newRes := restful.NewResponse(rw) + + chain.ProcessFilter(newReq, newRes) + if rw.Code != http.StatusOK { + http.Error(w, rw.Body.String(), rw.Code) + return + } + + v2Discovery := apidiscoveryv2.APIGroupDiscoveryList{} + if err := json.Unmarshal(rw.Body.Bytes(), &v2Discovery); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + clonedReq = req.Clone(req.Context()) + rw = httptest.NewRecorder() + + a.delegate.ServeHTTP(rw, clonedReq) + if rw.Code != http.StatusOK { + http.Error(w, rw.Body.String(), rw.Code) + return + } + + proxiedDiscovery := apidiscoveryv2.APIGroupDiscoveryList{} + if err := json.Unmarshal(rw.Body.Bytes(), &proxiedDiscovery); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + v2Discovery.Items = append(v2Discovery.Items, proxiedDiscovery.Items...) + responsewriters.WriteObjectNegotiated(a.codecs, aggregated.DiscoveryEndpointRestrictions, schema.GroupVersion{ + Group: apidiscoveryv2.SchemeGroupVersion.Group, + Version: apidiscoveryv2.SchemeGroupVersion.Version, + }, w, req, http.StatusOK, &v2Discovery, true) } +} - rw := httptest.NewRecorder() - a.delegationTarget.UnprotectedHandler().ServeHTTP(rw, req.Request) +func (a *apisProxyHandler) v1handler(chain *restful.FilterChain) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + clonedReq := req.Clone(req.Context()) + clonedReq.Header.Set("Accept", "application/json") + newReq := restful.NewRequest(clonedReq) + rw := httptest.NewRecorder() + newRes := restful.NewResponse(rw) - if rw.Code != http.StatusOK { - http.Error(resp.ResponseWriter, rw.Body.String(), rw.Code) - return - } + chain.ProcessFilter(newReq, newRes) + if rw.Code != http.StatusOK { + http.Error(w, rw.Body.String(), rw.Code) + return + } + + discoveryGroupList := metav1.APIGroupList{} + if err := json.Unmarshal(rw.Body.Bytes(), &discoveryGroupList); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + clonedReq = req.Clone(req.Context()) + clonedReq.Header.Set("Accept", "application/json") + rw = httptest.NewRecorder() - proxiedGroups := metav1.APIGroupList{} - if err := json.Unmarshal(rw.Body.Bytes(), &proxiedGroups); err != nil { - http.Error(resp.ResponseWriter, err.Error(), http.StatusInternalServerError) - return - } + a.delegate.ServeHTTP(rw, clonedReq) + if rw.Code != http.StatusOK { + http.Error(w, rw.Body.String(), rw.Code) + return + } - discoveryGroupList.Groups = append(discoveryGroupList.Groups, proxiedGroups.Groups...) + proxiedGroups := metav1.APIGroupList{} + if err := json.Unmarshal(rw.Body.Bytes(), &proxiedGroups); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - responsewriters.WriteObjectNegotiated(a.codecs, negotiation.DefaultEndpointRestrictions, schema.GroupVersion{}, resp.ResponseWriter, req.Request, http.StatusOK, discoveryGroupList, false) + discoveryGroupList.Groups = append(discoveryGroupList.Groups, proxiedGroups.Groups...) + responsewriters.WriteObjectNegotiated(a.codecs, negotiation.DefaultEndpointRestrictions, schema.GroupVersion{}, w, req, http.StatusOK, &discoveryGroupList, false) + } } diff --git a/pkg/aggregator/apiserver/handler_apis_test.go b/pkg/aggregator/apiserver/handler_apis_test.go new file mode 100644 index 00000000000..4c289fef898 --- /dev/null +++ b/pkg/aggregator/apiserver/handler_apis_test.go @@ -0,0 +1,233 @@ +package apiserver + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/emicklei/go-restful/v3" + "github.com/stretchr/testify/require" + v2 "k8s.io/api/apidiscovery/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/endpoints/discovery/aggregated" + + aggregationv0alpha1api "github.com/grafana/grafana/pkg/aggregator/apis/aggregation/v0alpha1" +) + +func TestApisProxyHandler_Handle(t *testing.T) { + v1Discovery := metav1.APIGroup{ + Name: aggregationv0alpha1api.SchemeGroupVersion.Group, + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: aggregationv0alpha1api.SchemeGroupVersion.String(), + Version: aggregationv0alpha1api.SchemeGroupVersion.Version, + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: aggregationv0alpha1api.SchemeGroupVersion.String(), + Version: aggregationv0alpha1api.SchemeGroupVersion.Version, + }, + } + fakeGroup := metav1.APIGroup{ + Name: "foo.example.com", + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: "foo.example.com/v1", + Version: "v1", + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: "foo.example.com/v1", + Version: "v1", + }, + } + fakeGroupList := metav1.APIGroupList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIGroupList", + APIVersion: "v1", + }, + Groups: []metav1.APIGroup{fakeGroup}, + } + + rm := aggregated.NewResourceManager("apis") + v2GroupVersion := v2.APIVersionDiscovery{ + Version: "v0alpha1", + Resources: []v2.APIResourceDiscovery{ + { + Resource: "dataplaneservices", + ResponseKind: &metav1.GroupVersionKind{ + Group: "aggregation.grafana.app", + Version: "v0alpha1", + Kind: "DataPlaneService", + }, + Scope: v2.ScopeCluster, + SingularResource: "dataplaneservice", + Verbs: []string{"list", "get", "create", "update", "delete", "patch", "watch"}, + }, + }, + Freshness: v2.DiscoveryFreshnessCurrent, + } + rm.AddGroupVersion("aggregation.grafana.app", v2GroupVersion) + + v2FakeGroupVersion := v2.APIVersionDiscovery{ + Version: "v1", + Resources: []v2.APIResourceDiscovery{ + { + Resource: "foos", + ResponseKind: &metav1.GroupVersionKind{ + Group: "foo.example.com", + Version: "v1", + Kind: "Foo", + }, + Scope: v2.ScopeNamespace, + SingularResource: "foo", + Verbs: []string{"list"}, + }, + }, + Freshness: v2.DiscoveryFreshnessStale, + } + + fakeRm := aggregated.NewResourceManager("apis") + rm.AddGroupVersion("foo.example.com", v2FakeGroupVersion) + + delegationTarget := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Header.Get("Accept") { + case "application/json": + err := json.NewEncoder(w).Encode(fakeGroupList) + require.NoError(t, err) + return + case "application/json;g=apidiscovery.k8s.io;v=v2;as=APIGroupDiscoveryList,application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json": + fakeRm.ServeHTTP(w, r) + return + default: + fmt.Printf("Accept: %s\n", r.Header.Get("Accept")) + http.Error(w, "Bad request", http.StatusBadRequest) + } + }) + + handler := newApisProxyHandler(delegationTarget) + + chain := &restful.FilterChain{ + Target: func(req *restful.Request, resp *restful.Response) { + switch req.Request.URL.Path { + case "/apis/aggregation.grafana.app/v0alpha1": + _, err := resp.ResponseWriter.Write([]byte("v0alpha1")) + require.NoError(t, err) + return + case "/apis": + switch req.Request.Header.Get("Accept") { + case "application/json": + err := json.NewEncoder(resp.ResponseWriter).Encode(&metav1.APIGroupList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIGroupList", + APIVersion: "v1", + }, + Groups: []metav1.APIGroup{v1Discovery}, + }) + require.NoError(t, err) + return + case "application/json;g=apidiscovery.k8s.io;v=v2;as=APIGroupDiscoveryList,application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json": + rm.ServeHTTP(resp.ResponseWriter, req.Request) + return + default: + fmt.Printf("Accept: %s\n", req.Request.Header.Get("Accept")) + http.Error(resp.ResponseWriter, "Bad request", http.StatusBadRequest) + } + + default: + http.Error(resp.ResponseWriter, "not found", http.StatusNotFound) + } + }, + } + + t.Run("should return the list of API groups in v1 format", func(t *testing.T) { + req := &restful.Request{ + Request: httptest.NewRequest(http.MethodGet, "/apis", nil), + } + req.Request.Header.Set("Accept", "application/json") + rec := httptest.NewRecorder() + resp := &restful.Response{ + ResponseWriter: rec, + } + + handler.handle(req, resp, chain) + require.Equal(t, http.StatusOK, resp.StatusCode()) + require.NoError(t, resp.Error()) + require.Equal(t, "application/json", resp.Header().Get("Content-Type")) + + expectedGroupList := metav1.APIGroupList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIGroupList", + APIVersion: "v1", + }, + Groups: append([]metav1.APIGroup{v1Discovery}, fakeGroupList.Groups...), + } + + actualGroupList := metav1.APIGroupList{} + err := json.NewDecoder(rec.Body).Decode(&actualGroupList) + require.NoError(t, err) + + require.Equal(t, expectedGroupList, actualGroupList) + }) + + t.Run("should return the list of API groups in v2 format", func(t *testing.T) { + req := &restful.Request{ + Request: httptest.NewRequest(http.MethodGet, "/apis", nil), + } + req.Request.Header.Set("Accept", "application/json;g=apidiscovery.k8s.io;v=v2;as=APIGroupDiscoveryList,application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json") + rec := httptest.NewRecorder() + resp := &restful.Response{ + ResponseWriter: rec, + } + + handler.handle(req, resp, chain) + require.Equal(t, http.StatusOK, resp.StatusCode()) + require.Equal(t, "application/json;g=apidiscovery.k8s.io;v=v2;as=APIGroupDiscoveryList", resp.Header().Get("Content-Type")) + + expected := v2.APIGroupDiscoveryList{ + TypeMeta: metav1.TypeMeta{ + Kind: "APIGroupDiscoveryList", + APIVersion: v2.SchemeGroupVersion.String(), + }, + ListMeta: metav1.ListMeta{}, + Items: []v2.APIGroupDiscovery{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "aggregation.grafana.app", + }, + Versions: []v2.APIVersionDiscovery{v2GroupVersion}, + }, + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "foo.example.com", + }, + Versions: []v2.APIVersionDiscovery{v2FakeGroupVersion}, + }, + }, + } + + actual := v2.APIGroupDiscoveryList{} + err := json.NewDecoder(rec.Body).Decode(&actual) + require.NoError(t, err) + + require.Equal(t, expected, actual) + }) + + t.Run("should handle the request if the path is not /apis", func(t *testing.T) { + req := &restful.Request{ + Request: httptest.NewRequest(http.MethodGet, "/apis/aggregation.grafana.app/v0alpha1", nil), + } + rec := httptest.NewRecorder() + resp := &restful.Response{ + ResponseWriter: rec, + } + + handler.handle(req, resp, chain) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "v0alpha1", rec.Body.String()) + }) +} diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index 5512000878a..273c755129b 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -9,6 +9,7 @@ require ( github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435 github.com/stretchr/testify v1.9.0 go.opentelemetry.io/otel v1.28.0 + k8s.io/api v0.31.0 k8s.io/apimachinery v0.31.0 k8s.io/apiserver v0.31.0 k8s.io/client-go v0.31.0 @@ -149,7 +150,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.31.0 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect