Skip to content

Commit

Permalink
Implement initial OpenAPI v3 support for virtual workspaces
Browse files Browse the repository at this point in the history
Signed-off-by: Marko Mudrinić <[email protected]>
  • Loading branch information
xmudrii committed Feb 5, 2025
1 parent 37065ca commit 385751c
Show file tree
Hide file tree
Showing 3 changed files with 273 additions and 86 deletions.
10 changes: 9 additions & 1 deletion pkg/virtual/framework/dynamic/apiserver/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import (
"k8s.io/apimachinery/pkg/runtime/serializer"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apiserver/pkg/endpoints/discovery"
openapinamer "k8s.io/apiserver/pkg/endpoints/openapi"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/apiserver/pkg/util/openapi"
generatedopenapi "k8s.io/kubernetes/pkg/generated/openapi"

virtualcontext "github.com/kcp-dev/kcp/pkg/virtual/framework/context"
"github.com/kcp-dev/kcp/pkg/virtual/framework/dynamic/apidefinition"
Expand Down Expand Up @@ -177,7 +180,12 @@ func (c completedConfig) New(virtualWorkspaceName string, delegationTarget gener

s.GenericAPIServer.Handler.GoRestfulContainer.Add(discovery.NewLegacyRootAPIHandler(c.GenericConfig.DiscoveryAddresses, s.GenericAPIServer.Serializer, "/api").WebService())

openAPIHandler := newOpenAPIHandler(s.APISetRetriever, delegateHandler)
getOpenAPIDefinitions := openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(generatedopenapi.GetOpenAPIDefinitions)
namer := openapinamer.NewDefinitionNamer(scheme)
openapiv3Config := genericapiserver.DefaultOpenAPIV3Config(getOpenAPIDefinitions, namer)
openapiv3Config.Info.Title = "Kubernetes"

openAPIHandler := newOpenAPIHandler(s.APISetRetriever, s.GenericAPIServer.Handler.GoRestfulContainer, openapiv3Config, delegateHandler)

s.GenericAPIServer.Handler.NonGoRestfulMux.Handle("/apis", crdHandler)
s.GenericAPIServer.Handler.NonGoRestfulMux.HandlePrefix("/apis/", crdHandler)
Expand Down
85 changes: 0 additions & 85 deletions pkg/virtual/framework/dynamic/apiserver/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ limitations under the License.
package apiserver

import (
"context"
"fmt"
"net/http"
"sort"
Expand All @@ -34,7 +33,6 @@ import (
"k8s.io/apiserver/pkg/endpoints/handlers/negotiation"
"k8s.io/apiserver/pkg/endpoints/handlers/responsewriters"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/kube-openapi/pkg/handler3"
"k8s.io/kubernetes/pkg/controlplane/apiserver/miniaggregator"

"github.com/kcp-dev/kcp/pkg/virtual/framework/dynamic/apidefinition"
Expand Down Expand Up @@ -332,86 +330,3 @@ func sortGroupDiscoveryByKubeAwareVersion(gd []metav1.GroupVersionForDiscovery)
return version.CompareKubeAwareVersionStrings(gd[i].Version, gd[j].Version) > 0
})
}

type openAPIHandler struct {
apiSetRetriever apidefinition.APIDefinitionSetGetter
delegate http.Handler

openAPIV3Service *handler3.OpenAPIService
}

func newOpenAPIHandler(apiSetRetriever apidefinition.APIDefinitionSetGetter, delegate http.Handler) *openAPIHandler {
openAPIV3Service := handler3.NewOpenAPIService()

return &openAPIHandler{
apiSetRetriever: apiSetRetriever,
delegate: delegate,

openAPIV3Service: openAPIV3Service,
}
}

func (h *openAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// handle /openapi -> do nothing
// handle /openapi/v2 -> do nothing for now. TODO: Implement. Actually, let OpenAPIV2 die. Ref: https://github.com/kcp-dev/kcp/pull/3059#discussion_r1424317153
// handle /openapi/v3 -> discovery endpoint
// handle /openapi/v3/apis/<group>/<version> -> serve OpenAPIV3Schema for <group>/<version>

pathParts := splitPath(r.URL.Path)

// if request doesn't start with /openapi return
// this check is a safety measure
if len(pathParts) == 1 && pathParts[0] != "openapi" {
h.delegate.ServeHTTP(w, r)
return
}

if len(pathParts) == 1 && pathParts[0] == "openapi" {
return
}

if len(pathParts) == 2 && pathParts[1] == "v2" { // handle /openapi/v2
// TODO: Implement OpenAPI V2 handler. Actually, let OpenAPIV2 die. Ref: https://github.com/kcp-dev/kcp/pull/3059#discussion_r1424317153
return
} else if len(pathParts) == 2 && pathParts[1] == "v3" { // handle /openapi/v3
if err := h.fetchAndUpdate(r.Context(), ""); err != nil {
responsewriters.ErrorNegotiated(
apierrors.NewInternalError(fmt.Errorf("unable to set: %w", err)),
errorCodecs, schema.GroupVersion{},
w, r)
}

h.openAPIV3Service.HandleDiscovery(w, r)
return
} else if len(pathParts) == 5 && pathParts[1] == "v3" && pathParts[2] == "apis" {
requestedGroup := pathParts[3]
if err := h.fetchAndUpdate(r.Context(), requestedGroup); err != nil {
responsewriters.ErrorNegotiated(
apierrors.NewInternalError(fmt.Errorf("unable to set: %w", err)),
errorCodecs, schema.GroupVersion{},
w, r)
}

h.openAPIV3Service.HandleGroupVersion(w, r)
} else {
h.delegate.ServeHTTP(w, r)
return
}
}

func (h *openAPIHandler) fetchAndUpdate(ctx context.Context, group string) error {
apiDomainKey := dyncamiccontext.APIDomainKeyFrom(ctx)
apiSet, _, err := h.apiSetRetriever.GetAPIDefinitionSet(ctx, apiDomainKey)
if err != nil {
return err
}

for gvr, apiDefinition := range apiSet {
if group == "" || (group != "" && group == gvr.Group) {
spec := apiDefinition.GetOpenAPIV3Spec()
h.openAPIV3Service.UpdateGroupVersion(gvr.Group, spec)
}
}

return nil
}
264 changes: 264 additions & 0 deletions pkg/virtual/framework/dynamic/apiserver/openapi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/*
Copyright 2025 The KCP 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 apiserver

import (
"fmt"
"net/http"

"github.com/emicklei/go-restful/v3"

apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apiextensions-apiserver/pkg/controller/openapi/builder"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/endpoints/discovery"
"k8s.io/apiserver/pkg/endpoints/handlers/responsewriters"
"k8s.io/apiserver/pkg/server/mux"
"k8s.io/kube-openapi/pkg/builder3"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/common/restfuladapter"
"k8s.io/kube-openapi/pkg/handler3"
"k8s.io/kube-openapi/pkg/spec3"

"github.com/kcp-dev/kcp/pkg/virtual/framework/dynamic/apidefinition"
dyncamiccontext "github.com/kcp-dev/kcp/pkg/virtual/framework/dynamic/context"
apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
)

/*
*
openAPIHandler implements the OpenAPI v3 handler for virtual workspaces.
This handler generates the OpenAPI v3 spec by:
- Creating specs based on webservices registered with the virtual workspace API server. These are only /api, /apis,
and /version, without any subroute (e.g. /apis/<group>)
- Creating a webservice for every /apis/<group> route available by utilizing the API discovery, then generating
OpenAPI v3 specs from these webservices
- Converting every APIDefinition into a CustomResourceDefinition and generating OpenAPI v3 specs for
/apis/<group>/<version> from these CRDs using the kube-openapi library
This is different to the regular API servers, which are generating the OpenAPI v3 spec by purely using the webservices.
This is not an option here because the virtual workspace API server has only three webservices registered
(/api, /apis, and /version, without any subroute).
*/
type openAPIHandler struct {
apiSetRetriever apidefinition.APIDefinitionSetGetter
goRestfulContainer *restful.Container
openapiv3Config *common.OpenAPIV3Config
delegate http.Handler

openAPIV3Service *handler3.OpenAPIService
}

func newOpenAPIHandler(
apiSetRetriever apidefinition.APIDefinitionSetGetter,
goRestfulContainer *restful.Container,
openapiv3Config *common.OpenAPIV3Config,
delegate http.Handler) *openAPIHandler {
openAPIV3Service := handler3.NewOpenAPIService()

return &openAPIHandler{
apiSetRetriever: apiSetRetriever,
goRestfulContainer: goRestfulContainer,
openapiv3Config: openapiv3Config,
delegate: delegate,

openAPIV3Service: openAPIV3Service,
}
}

func (o *openAPIHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
apiDomainKey := dyncamiccontext.APIDomainKeyFrom(ctx)

// Get all available APIDefinitions for this Virtual Workspace
apiSet, hasLocationKey, err := o.apiSetRetriever.GetAPIDefinitionSet(ctx, apiDomainKey)
if err != nil {
responsewriters.ErrorNegotiated(
apierrors.NewInternalError(fmt.Errorf("unable to determine API definition set: %w", err)),
errorCodecs, schema.GroupVersion{},
w, req)
return
}
if !hasLocationKey {
o.delegate.ServeHTTP(w, req)
return
}

// TODO(xmudrii): Add ordering here.
// TODO(xmudrii): Add caching here.

// Collect routes registered in the virtual workspace API server: /api, /apis, and /version.
// Subroutes (e.g. /apis/<group> and /apis/<group>/<version>) are not included and are handled separately below.
webservices := make(map[string][]*restful.WebService)
for _, t := range o.goRestfulContainer.RegisteredWebServices() {
// Strip the "/" prefix from the name
gvPath := t.RootPath()[1:]
webservices[gvPath] = []*restful.WebService{t}
}

// Create web services for /apis/<group>
versionsForDiscoveryMap := make(map[schema.GroupVersion][]metav1.GroupVersionForDiscovery)
for gvr := range apiSet {
if gvr.Group == "" {
continue
}
gv := schema.GroupVersion{
Group: gvr.Group,
Version: gvr.Version,
}

if versionsForDiscoveryMap[gv] == nil {
versionsForDiscoveryMap[gv] = []metav1.GroupVersionForDiscovery{}
}

versionsForDiscoveryMap[gv] = append(versionsForDiscoveryMap[gv], metav1.GroupVersionForDiscovery{
GroupVersion: gvr.GroupVersion().String(),
Version: gvr.Version,
})
}
for gv := range versionsForDiscoveryMap {
apiGroup := metav1.APIGroup{
Name: gv.Group,
Versions: versionsForDiscoveryMap[gv],
// the preferred versions for a group is the first item in
// apiVersionsForDiscovery after it put in the right ordered
PreferredVersion: versionsForDiscoveryMap[gv][0],
}

groupPath := "apis/" + gv.Group
webservices[groupPath] = append(webservices[groupPath], discovery.NewAPIGroupHandler(codecs, apiGroup).WebService())
}

// Build OpenAPI v3 spec for /apis/<group> webservices
routeSpecs := make(map[string][]*spec3.OpenAPI)
for groupPath, ws := range webservices {
spec, err := builder3.BuildOpenAPISpecFromRoutes(restfuladapter.AdaptWebServices(ws), o.openapiv3Config)
if err != nil {
responsewriters.InternalError(w, req, err)
return
}
routeSpecs[groupPath] = []*spec3.OpenAPI{spec}
}

// Build OpenAPI v3 specs for /apis/<group>/<version>, i.e. build OpenAPI v3 specs for each APIDefinition
resourceSpecs := make([]map[string][]*spec3.OpenAPI, 0, len(apiSet))
for _, apiDefinition := range apiSet {
specs, err := apiResourceSchemaToSpec(apiDefinition.GetAPIResourceSchema())
if err != nil {
responsewriters.InternalError(w, req, err)
return
}
resourceSpecs = append(resourceSpecs, specs)
}

// Create a new OpenAPI service
m := mux.NewPathRecorderMux("virtual-workspace-openapi-v3")
service := handler3.NewOpenAPIService()
err = service.RegisterOpenAPIV3VersionedService("/openapi/v3", m)
if err != nil {
responsewriters.InternalError(w, req, err)
return
}

// Add all OpenAPI v3 specs to the OpenAPI service
err = addSpecs(service, routeSpecs, resourceSpecs)
if err != nil {
responsewriters.InternalError(w, req, err)
return
}

m.ServeHTTP(w, req)
}

func apiResourceSchemaToSpec(apiResourceSchema *apisv1alpha1.APIResourceSchema) (map[string][]*spec3.OpenAPI, error) {
specs := map[string][]*spec3.OpenAPI{}
versions := make([]apiextensionsv1.CustomResourceDefinitionVersion, 0, len(apiResourceSchema.Spec.Versions))

for _, ver := range apiResourceSchema.Spec.Versions {
verSchema, err := ver.GetSchema()
if err != nil {
return nil, err
}

versions = append(versions, apiextensionsv1.CustomResourceDefinitionVersion{
Name: ver.Name,
Schema: &apiextensionsv1.CustomResourceValidation{
OpenAPIV3Schema: verSchema,
},
Subresources: &ver.Subresources,
})
}

crd := &apiextensionsv1.CustomResourceDefinition{
Spec: apiextensionsv1.CustomResourceDefinitionSpec{
Group: apiResourceSchema.Spec.Group,
Names: apiResourceSchema.Spec.Names,
Versions: versions,
Scope: apiResourceSchema.Spec.Scope,
},
}

for _, ver := range versions {
spec, err := builder.BuildOpenAPIV3(crd, ver.Name, builder.Options{V2: false})
if err != nil {
return nil, err
}

gv := schema.GroupVersion{Group: crd.Spec.Group, Version: ver.Name}
gvPath := groupVersionToOpenAPIV3Path(gv)
specs[gvPath] = append(specs[gvPath], spec)
}

return specs, nil
}

func groupVersionToOpenAPIV3Path(gv schema.GroupVersion) string {
if gv.Group == "" {
return "api/" + gv.Version
}
return "apis/" + gv.Group + "/" + gv.Version
}

func addSpecs(service *handler3.OpenAPIService, routeSpecs map[string][]*spec3.OpenAPI, apiDefSpecs []map[string][]*spec3.OpenAPI) error {
byGroupVersionSpecs := make(map[string][]*spec3.OpenAPI)

for groupPath, spec := range routeSpecs {
byGroupVersionSpecs[groupPath] = spec
}

for _, apiDefSpec := range apiDefSpecs {
for gvPath, spec := range apiDefSpec {
byGroupVersionSpecs[gvPath] = spec
}
}

// lazily merge spec and add to service
for gvPath, specs := range byGroupVersionSpecs {
gvSpec, err := builder.MergeSpecsV3(specs...)
if err != nil {
return fmt.Errorf("failed to merge specs: %v", err)
}

service.UpdateGroupVersion(gvPath, gvSpec)
}

return nil
}

0 comments on commit 385751c

Please sign in to comment.