Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add OpenAPI Schema support to virtual workspace framework #3059

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg/virtual/framework/dynamic/apidefinition/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/endpoints/handlers"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/kube-openapi/pkg/spec3"

dynamiccontext "github.com/kcp-dev/kcp/pkg/virtual/framework/dynamic/context"
apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
Expand All @@ -34,6 +35,9 @@ type APIDefinition interface {
// GetAPIResourceSchema returns the API schema this definition serves.
GetAPIResourceSchema() *apisv1alpha1.APIResourceSchema

// GetOpenAPIV3Schema fetches OpenAPI V3 Schema
GetOpenAPIV3Spec() *spec3.OpenAPI

// GetClusterName provides the name of the logical cluster where the resource specification comes from.
GetClusterName() logicalcluster.Name

Expand Down
9 changes: 4 additions & 5 deletions pkg/virtual/framework/dynamic/apiserver/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,13 @@ 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)

s.GenericAPIServer.Handler.NonGoRestfulMux.Handle("/apis", crdHandler)
s.GenericAPIServer.Handler.NonGoRestfulMux.HandlePrefix("/apis/", crdHandler)
s.GenericAPIServer.Handler.NonGoRestfulMux.Handle("/api/v1", crdHandler)
s.GenericAPIServer.Handler.NonGoRestfulMux.HandlePrefix("/api/v1/", crdHandler)

// TODO(david): plug OpenAPI if necessary. For now, according to the various virtual workspace use-cases,
// it doesn't seem necessary.
// Of course this requires using the --validate=false argument with some kubectl command like kubectl apply.

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

import (
"context"
"fmt"
"net/http"
"sort"
Expand All @@ -33,6 +34,7 @@ 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 @@ -330,3 +332,86 @@ func sortGroupDiscoveryByKubeAwareVersion(gd []metav1.GroupVersionForDiscovery)
return version.CompareKubeAwareVersionStrings(gd[i].Version, gd[j].Version) > 0
})
}

type openAPIHandler struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you describe on the high level how this handler gets a v3 schema for the right resources?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

go doc I mean

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I am not mistaken, die service is not workspace and binding aware.

} 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this synchronous?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It lazily updates the cache, IIUC.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sttts Do we prefer/require synchronous or asynchronous (lazy update) in this case?

}
}

return nil
}
5 changes: 5 additions & 0 deletions pkg/virtual/framework/dynamic/apiserver/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import (
apirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
utilopenapi "k8s.io/apiserver/pkg/util/openapi"
"k8s.io/kube-openapi/pkg/spec3"
"sigs.k8s.io/yaml"

"github.com/kcp-dev/kcp/pkg/virtual/framework/dynamic/apidefinition"
Expand All @@ -58,6 +59,7 @@ type mockedAPIDefinition struct {
apiResourceSchema *apisv1alpha1.APIResourceSchema
store rest.Storage
subresourcesStores map[string]rest.Storage
openAPIV3Spec *spec3.OpenAPI
}

var _ apidefinition.APIDefinition = (*mockedAPIDefinition)(nil)
Expand All @@ -71,6 +73,9 @@ func (apiDef *mockedAPIDefinition) GetClusterName() logicalcluster.Name {
func (apiDef *mockedAPIDefinition) GetStorage() rest.Storage {
return apiDef.store
}
func (apiDef *mockedAPIDefinition) GetOpenAPIV3Spec() *spec3.OpenAPI {
return apiDef.openAPIV3Spec
}
func (apiDef *mockedAPIDefinition) GetSubResourceStorage(subresource string) rest.Storage {
return apiDef.subresourcesStores[subresource]
}
Expand Down
43 changes: 41 additions & 2 deletions pkg/virtual/framework/dynamic/apiserver/serving_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
genericapiserver "k8s.io/apiserver/pkg/server"
utilopenapi "k8s.io/apiserver/pkg/util/openapi"
"k8s.io/klog/v2"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"

kcpfeatures "github.com/kcp-dev/kcp/pkg/features"
Expand Down Expand Up @@ -108,6 +109,17 @@ func CreateServingInfoFor(genericConfig genericapiserver.CompletedConfig, apiRes
return nil, err
}

openAPIV3Spec, err := buildOpenAPIV3(
apiResourceSchema,
apiResourceVersion,
builder.Options{
V2: false,
SkipFilterSchemaForKubectlOpenAPIV2Validation: true,
AllowNonStructural: false})
if err != nil {
return nil, err
}

var modelsByGKV openapi.ModelsByGKV

openAPIModels, err := utilopenapi.ToProtoModels(s)
Expand Down Expand Up @@ -302,6 +314,7 @@ func CreateServingInfoFor(genericConfig genericapiserver.CompletedConfig, apiRes
requestScope: requestScope,
statusRequestScope: &statusScope,
logicalClusterName: logicalcluster.From(apiResourceSchema),
openAPIV3Spec: openAPIV3Spec,
}

return ret, nil
Expand All @@ -317,6 +330,8 @@ type servingInfo struct {

requestScope *handlers.RequestScope
statusRequestScope *handlers.RequestScope

openAPIV3Spec *spec3.OpenAPI
}

// Implement APIDefinition interface
Expand Down Expand Up @@ -348,6 +363,10 @@ func (apiDef *servingInfo) GetSubResourceRequestScope(subresource string) *handl
func (apiDef *servingInfo) TearDown() {
}

func (apiDef *servingInfo) GetOpenAPIV3Spec() *spec3.OpenAPI {
return apiDef.openAPIV3Spec
}

var _ runtime.ObjectConvertor = nopConverter{}

type nopConverter struct{}
Expand All @@ -371,8 +390,7 @@ func (u nopConverter) ConvertFieldLabel(gvk schema.GroupVersionKind, label, valu
return label, value, nil
}

// buildOpenAPIV2 builds OpenAPI v2 for the given apiResourceSpec.
func buildOpenAPIV2(apiResourceSchema *apisv1alpha1.APIResourceSchema, apiResourceVersion *apisv1alpha1.APIResourceVersion, opts builder.Options) (*spec.Swagger, error) {
func getCRDFromSchema(apiResourceSchema *apisv1alpha1.APIResourceSchema, apiResourceVersion *apisv1alpha1.APIResourceVersion) (*apiextensionsv1.CustomResourceDefinition, error) {
openapiSchema, err := apiResourceVersion.GetSchema()
if err != nil {
return nil, err
Expand All @@ -393,9 +411,30 @@ func buildOpenAPIV2(apiResourceSchema *apisv1alpha1.APIResourceSchema, apiResour
Scope: apiResourceSchema.Spec.Scope,
},
}

return crd, nil
}

// buildOpenAPIV2 builds OpenAPI v2 for the given apiResourceSpec.
func buildOpenAPIV2(apiResourceSchema *apisv1alpha1.APIResourceSchema, apiResourceVersion *apisv1alpha1.APIResourceVersion, opts builder.Options) (*spec.Swagger, error) {
crd, err := getCRDFromSchema(apiResourceSchema, apiResourceVersion)
if err != nil {
return nil, err
}

return builder.BuildOpenAPIV2(crd, apiResourceVersion.Name, opts)
}

// buildOpenAPIV3 builds OpenAPI v3 for the given apiResourceSpec.
func buildOpenAPIV3(apiResourceSchema *apisv1alpha1.APIResourceSchema, apiResourceVersion *apisv1alpha1.APIResourceVersion, opts builder.Options) (*spec3.OpenAPI, error) {
crd, err := getCRDFromSchema(apiResourceSchema, apiResourceVersion)
if err != nil {
return nil, err
}

return builder.BuildOpenAPIV3(crd, apiResourceVersion.Name, opts)
}

func findAPIResourceVersion(schema *apisv1alpha1.APIResourceSchema, version string) (*apisv1alpha1.APIResourceVersion, bool) {
for i := range schema.Spec.Versions {
if vs := &schema.Spec.Versions[i]; vs.Name == version {
Expand Down