Make CRDs built and aggregated lazily for oasv2

This commit is contained in:
Jefftree 2023-05-24 15:36:10 +00:00
parent b2a9c06b2e
commit 735be024cf
3 changed files with 641 additions and 77 deletions

View File

@ -18,10 +18,10 @@ package openapi
import (
"fmt"
"reflect"
"sync"
"time"
"github.com/google/uuid"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/labels"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
@ -29,6 +29,7 @@ import (
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/util/workqueue"
"k8s.io/klog/v2"
"k8s.io/kube-openapi/pkg/cached"
"k8s.io/kube-openapi/pkg/handler"
"k8s.io/kube-openapi/pkg/validation/spec"
@ -49,21 +50,69 @@ type Controller struct {
queue workqueue.RateLimitingInterface
staticSpec *spec.Swagger
staticSpec *spec.Swagger
openAPIService *handler.OpenAPIService
// specs per version and per CRD name
lock sync.Mutex
crdSpecs map[string]map[string]*spec.Swagger
// specs by name. The specs are lazily constructed on request.
// The lock is for the map only.
lock sync.Mutex
specsByName map[string]*specCache
}
// specCache holds the merged version spec for a CRD as well as the CRD object.
// The spec is created lazily from the CRD object on request.
// The mergedVersionSpec is only created on instantiation and is never
// changed. crdCache is a cached.Replaceable and updates are thread
// safe. Thus, no lock is needed to protect this struct.
type specCache struct {
crdCache cached.Replaceable[*apiextensionsv1.CustomResourceDefinition]
mergedVersionSpec cached.Data[*spec.Swagger]
}
func (s *specCache) update(crd *apiextensionsv1.CustomResourceDefinition) {
s.crdCache.Replace(cached.NewResultOK(crd, generateCRDHash(crd)))
}
func createSpecCache(crd *apiextensionsv1.CustomResourceDefinition) *specCache {
s := specCache{}
s.update(crd)
s.mergedVersionSpec = cached.NewTransformer[*apiextensionsv1.CustomResourceDefinition](func(result cached.Result[*apiextensionsv1.CustomResourceDefinition]) cached.Result[*spec.Swagger] {
if result.Err != nil {
// This should never happen, but return the err if it does.
return cached.NewResultErr[*spec.Swagger](result.Err)
}
crd := result.Data
mergeSpec := &spec.Swagger{}
for _, v := range crd.Spec.Versions {
if !v.Served {
continue
}
s, err := builder.BuildOpenAPIV2(crd, v.Name, builder.Options{V2: true})
// Defaults must be pruned here for CRDs to cleanly merge with the static
// spec that already has defaults pruned
if err != nil {
return cached.NewResultErr[*spec.Swagger](err)
}
s.Definitions = handler.PruneDefaults(s.Definitions)
mergeSpec, err = builder.MergeSpecs(mergeSpec, s)
if err != nil {
return cached.NewResultErr[*spec.Swagger](err)
}
}
return cached.NewResultOK(mergeSpec, generateCRDHash(crd))
}, &s.crdCache)
return &s
}
// NewController creates a new Controller with input CustomResourceDefinition informer
func NewController(crdInformer informers.CustomResourceDefinitionInformer) *Controller {
c := &Controller{
crdLister: crdInformer.Lister(),
crdsSynced: crdInformer.Informer().HasSynced,
queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "crd_openapi_controller"),
crdSpecs: map[string]map[string]*spec.Swagger{},
crdLister: crdInformer.Lister(),
crdsSynced: crdInformer.Informer().HasSynced,
queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "crd_openapi_controller"),
specsByName: map[string]*specCache{},
}
crdInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
@ -102,18 +151,9 @@ func (c *Controller) Run(staticSpec *spec.Swagger, openAPIService *handler.OpenA
if !apiextensionshelpers.IsCRDConditionTrue(crd, apiextensionsv1.Established) {
continue
}
newSpecs, changed, err := buildVersionSpecs(crd, nil)
if err != nil {
utilruntime.HandleError(fmt.Errorf("failed to build OpenAPI spec of CRD %s: %v", crd.Name, err))
} else if !changed {
continue
}
c.crdSpecs[crd.Name] = newSpecs
}
if err := c.updateSpecLocked(); err != nil {
utilruntime.HandleError(fmt.Errorf("failed to initially create OpenAPI spec for CRDs: %v", err))
return
c.specsByName[crd.Name] = createSpecCache(crd)
}
c.updateSpecLocked()
// only start one worker thread since its a slow moving API
go wait.Until(c.runWorker, time.Second, stopCh)
@ -164,76 +204,59 @@ func (c *Controller) sync(name string) error {
// do we have to remove all specs of this CRD?
if errors.IsNotFound(err) || !apiextensionshelpers.IsCRDConditionTrue(crd, apiextensionsv1.Established) {
if _, found := c.crdSpecs[name]; !found {
if _, found := c.specsByName[name]; !found {
return nil
}
delete(c.crdSpecs, name)
delete(c.specsByName, name)
klog.V(2).Infof("Updating CRD OpenAPI spec because %s was removed", name)
regenerationCounter.With(map[string]string{"crd": name, "reason": "remove"})
return c.updateSpecLocked()
}
// compute CRD spec and see whether it changed
oldSpecs, updated := c.crdSpecs[crd.Name]
newSpecs, changed, err := buildVersionSpecs(crd, oldSpecs)
if err != nil {
return err
}
if !changed {
c.updateSpecLocked()
return nil
}
// update specs of this CRD
c.crdSpecs[crd.Name] = newSpecs
// If CRD spec already exists, update the CRD.
// specCache.update() includes the ETag so an update on a spec
// resulting in the same ETag will be a noop.
s, exists := c.specsByName[crd.Name]
if exists {
s.update(crd)
klog.V(2).Infof("Updating CRD OpenAPI spec because %s changed", name)
regenerationCounter.With(map[string]string{"crd": name, "reason": "update"})
return nil
}
c.specsByName[crd.Name] = createSpecCache(crd)
klog.V(2).Infof("Updating CRD OpenAPI spec because %s changed", name)
reason := "add"
if updated {
reason = "update"
}
regenerationCounter.With(map[string]string{"crd": name, "reason": reason})
return c.updateSpecLocked()
regenerationCounter.With(map[string]string{"crd": name, "reason": "add"})
c.updateSpecLocked()
return nil
}
func buildVersionSpecs(crd *apiextensionsv1.CustomResourceDefinition, oldSpecs map[string]*spec.Swagger) (map[string]*spec.Swagger, bool, error) {
newSpecs := map[string]*spec.Swagger{}
anyChanged := false
for _, v := range crd.Spec.Versions {
if !v.Served {
continue
// updateSpecLocked updates the cached spec graph.
func (c *Controller) updateSpecLocked() {
specList := make([]cached.Data[*spec.Swagger], 0, len(c.specsByName))
for crd := range c.specsByName {
specList = append(specList, c.specsByName[crd].mergedVersionSpec)
}
cache := cached.NewListMerger(func(results []cached.Result[*spec.Swagger]) cached.Result[*spec.Swagger] {
localCRDSpec := make([]*spec.Swagger, 0, len(results))
for k := range results {
if results[k].Err == nil {
localCRDSpec = append(localCRDSpec, results[k].Data)
}
}
spec, err := builder.BuildOpenAPIV2(crd, v.Name, builder.Options{V2: true})
// Defaults must be pruned here for CRDs to cleanly merge with the static
// spec that already has defaults pruned
spec.Definitions = handler.PruneDefaults(spec.Definitions)
mergedSpec, err := builder.MergeSpecs(c.staticSpec, localCRDSpec...)
if err != nil {
return nil, false, err
return cached.NewResultErr[*spec.Swagger](fmt.Errorf("failed to merge specs: %v", err))
}
newSpecs[v.Name] = spec
if oldSpecs[v.Name] == nil || !reflect.DeepEqual(oldSpecs[v.Name], spec) {
anyChanged = true
}
}
if !anyChanged && len(oldSpecs) == len(newSpecs) {
return newSpecs, false, nil
}
return newSpecs, true, nil
}
// updateSpecLocked aggregates all OpenAPI specs and updates openAPIService.
// It is not thread-safe. The caller is responsible to hold proper lock (Controller.lock).
func (c *Controller) updateSpecLocked() error {
crdSpecs := []*spec.Swagger{}
for _, versionSpecs := range c.crdSpecs {
for _, s := range versionSpecs {
crdSpecs = append(crdSpecs, s)
}
}
mergedSpec, err := builder.MergeSpecs(c.staticSpec, crdSpecs...)
if err != nil {
return fmt.Errorf("failed to merge specs: %v", err)
}
return c.openAPIService.UpdateSpec(mergedSpec)
// A UUID is returned for the etag because we will only
// create a new merger when a CRD has changed. A hash based
// etag is more expensive because the CRDs are not
// premarshalled.
return cached.NewResultOK(mergedSpec, uuid.New().String())
}, specList)
c.openAPIService.UpdateSpecLazy(cache)
}
func (c *Controller) addCustomResourceDefinition(obj interface{}) {
@ -269,3 +292,7 @@ func (c *Controller) deleteCustomResourceDefinition(obj interface{}) {
func (c *Controller) enqueue(obj *apiextensionsv1.CustomResourceDefinition) {
c.queue.Add(obj.Name)
}
func generateCRDHash(crd *apiextensionsv1.CustomResourceDefinition) string {
return fmt.Sprintf("%s,%d", crd.UID, crd.Generation)
}

View File

@ -0,0 +1,425 @@
/*
Copyright 2023 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 openapi
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
"k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/fake"
"k8s.io/apiextensions-apiserver/pkg/client/informers/externalversions"
"k8s.io/kube-openapi/pkg/handler"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/kube-openapi/pkg/validation/spec"
)
func TestBasicAddRemove(t *testing.T) {
env, ctx := setup(t)
env.runFunc()
defer env.cleanFunc()
env.Interface.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, coolFooCRD, metav1.CreateOptions{})
env.pollForPathExists("/apis/stable.example.com/v1/coolfoos")
s := env.fetchOpenAPIOrDie()
env.expectPath(s, "/apis/stable.example.com/v1/coolfoos")
env.expectPath(s, "/apis/apiextensions.k8s.io/v1")
t.Logf("Removing CRD %s", coolFooCRD.Name)
env.Interface.ApiextensionsV1().CustomResourceDefinitions().Delete(ctx, coolFooCRD.Name, metav1.DeleteOptions{})
env.pollForPathNotExists("/apis/stable.example.com/v1/coolfoos")
s = env.fetchOpenAPIOrDie()
env.expectNoPath(s, "/apis/stable.example.com/v1/coolfoos")
}
func TestTwoCRDsSameGroup(t *testing.T) {
env, ctx := setup(t)
env.runFunc()
defer env.cleanFunc()
env.Interface.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, coolFooCRD, metav1.CreateOptions{})
env.Interface.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, coolBarCRD, metav1.CreateOptions{})
env.pollForPathExists("/apis/stable.example.com/v1/coolfoos")
env.pollForPathExists("/apis/stable.example.com/v1/coolbars")
s := env.fetchOpenAPIOrDie()
env.expectPath(s, "/apis/stable.example.com/v1/coolfoos")
env.expectPath(s, "/apis/stable.example.com/v1/coolbars")
env.expectPath(s, "/apis/apiextensions.k8s.io/v1")
}
func TestCRDMultiVersion(t *testing.T) {
env, ctx := setup(t)
env.runFunc()
defer env.cleanFunc()
env.Interface.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, coolMultiVersion, metav1.CreateOptions{})
env.pollForPathExists("/apis/stable.example.com/v1/coolbars")
env.pollForPathExists("/apis/stable.example.com/v1beta1/coolbars")
s := env.fetchOpenAPIOrDie()
env.expectPath(s, "/apis/stable.example.com/v1/coolbars")
env.expectPath(s, "/apis/stable.example.com/v1beta1/coolbars")
env.expectPath(s, "/apis/apiextensions.k8s.io/v1")
}
func TestCRDMultiVersionUpdate(t *testing.T) {
env, ctx := setup(t)
env.runFunc()
defer env.cleanFunc()
crd, _ := env.Interface.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, coolMultiVersion, metav1.CreateOptions{})
env.pollForPathExists("/apis/stable.example.com/v1/coolbars")
env.pollForPathExists("/apis/stable.example.com/v1beta1/coolbars")
s := env.fetchOpenAPIOrDie()
env.expectPath(s, "/apis/stable.example.com/v1/coolbars")
env.expectPath(s, "/apis/stable.example.com/v1beta1/coolbars")
env.expectPath(s, "/apis/apiextensions.k8s.io/v1")
t.Log("Removing version v1beta1")
crd.Spec.Versions = crd.Spec.Versions[1:]
crd.Generation += 1
// Generation is updated before storage to etcd. Since we don't have that in the fake client, manually increase it.
env.Interface.ApiextensionsV1().CustomResourceDefinitions().Update(ctx, crd, metav1.UpdateOptions{})
env.pollForPathNotExists("/apis/stable.example.com/v1beta1/coolbars")
s = env.fetchOpenAPIOrDie()
env.expectPath(s, "/apis/stable.example.com/v1/coolbars")
env.expectNoPath(s, "/apis/stable.example.com/v1beta1/coolbars")
env.expectPath(s, "/apis/apiextensions.k8s.io/v1")
}
func TestExistingCRDBeforeAPIServerStart(t *testing.T) {
env, ctx := setup(t)
defer env.cleanFunc()
env.Interface.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, coolFooCRD, metav1.CreateOptions{})
env.runFunc()
env.pollForPathExists("/apis/stable.example.com/v1/coolfoos")
s := env.fetchOpenAPIOrDie()
env.expectPath(s, "/apis/stable.example.com/v1/coolfoos")
env.expectPath(s, "/apis/apiextensions.k8s.io/v1")
}
func TestUpdate(t *testing.T) {
env, ctx := setup(t)
env.runFunc()
defer env.cleanFunc()
crd, _ := env.Interface.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, coolFooCRD, metav1.CreateOptions{})
env.pollForPathExists("/apis/stable.example.com/v1/coolfoos")
s := env.fetchOpenAPIOrDie()
env.expectPath(s, "/apis/stable.example.com/v1/coolfoos")
env.expectPath(s, "/apis/apiextensions.k8s.io/v1")
t.Log("Updating CRD CoolFoo")
crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["num"] = v1.JSONSchemaProps{Type: "integer", Description: "updated description"}
crd.Generation += 1
// Generation is updated before storage to etcd. Since we don't have that in the fake client, manually increase it.
env.Interface.ApiextensionsV1().CustomResourceDefinitions().Update(ctx, crd, metav1.UpdateOptions{})
env.pollForCondition(func(s *spec.Swagger) bool {
return s.Definitions["com.example.stable.v1.CoolFoo"].Properties["num"].Description == crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["num"].Description
})
s = env.fetchOpenAPIOrDie()
// Ensure that description is updated
if s.Definitions["com.example.stable.v1.CoolFoo"].Properties["num"].Description != crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["num"].Description {
t.Error("Error: Description not updated")
}
env.expectPath(s, "/apis/stable.example.com/v1/coolfoos")
}
var coolFooCRD = &v1.CustomResourceDefinition{
TypeMeta: metav1.TypeMeta{
APIVersion: "apiextensions.k8s.io/v1",
Kind: "CustomResourceDefinition",
},
ObjectMeta: metav1.ObjectMeta{
Name: "coolfoo.stable.example.com",
},
Spec: v1.CustomResourceDefinitionSpec{
Group: "stable.example.com",
Names: v1.CustomResourceDefinitionNames{
Plural: "coolfoos",
Singular: "coolfoo",
ShortNames: []string{"foo"},
Kind: "CoolFoo",
ListKind: "CoolFooList",
},
Scope: v1.ClusterScoped,
Versions: []v1.CustomResourceDefinitionVersion{
{
Name: "v1",
Served: true,
Storage: true,
Deprecated: false,
Subresources: &v1.CustomResourceSubresources{
// This CRD has a /status subresource
Status: &v1.CustomResourceSubresourceStatus{},
},
Schema: &v1.CustomResourceValidation{
OpenAPIV3Schema: &v1.JSONSchemaProps{
Type: "object",
Properties: map[string]v1.JSONSchemaProps{"num": {Type: "integer", Description: "description"}},
},
},
},
},
Conversion: &v1.CustomResourceConversion{},
},
Status: v1.CustomResourceDefinitionStatus{
Conditions: []v1.CustomResourceDefinitionCondition{
{
Type: v1.Established,
Status: v1.ConditionTrue,
},
},
},
}
var coolBarCRD = &v1.CustomResourceDefinition{
TypeMeta: metav1.TypeMeta{
APIVersion: "apiextensions.k8s.io/v1",
Kind: "CustomResourceDefinition",
},
ObjectMeta: metav1.ObjectMeta{
Name: "coolbar.stable.example.com",
},
Spec: v1.CustomResourceDefinitionSpec{
Group: "stable.example.com",
Names: v1.CustomResourceDefinitionNames{
Plural: "coolbars",
Singular: "coolbar",
ShortNames: []string{"bar"},
Kind: "CoolBar",
ListKind: "CoolBarList",
},
Scope: v1.ClusterScoped,
Versions: []v1.CustomResourceDefinitionVersion{
{
Name: "v1",
Served: true,
Storage: true,
Deprecated: false,
Subresources: &v1.CustomResourceSubresources{
// This CRD has a /status subresource
Status: &v1.CustomResourceSubresourceStatus{},
},
Schema: &v1.CustomResourceValidation{
OpenAPIV3Schema: &v1.JSONSchemaProps{
Type: "object",
Properties: map[string]v1.JSONSchemaProps{"num": {Type: "integer", Description: "description"}},
},
},
},
},
Conversion: &v1.CustomResourceConversion{},
},
Status: v1.CustomResourceDefinitionStatus{
Conditions: []v1.CustomResourceDefinitionCondition{
{
Type: v1.Established,
Status: v1.ConditionTrue,
},
},
},
}
var coolMultiVersion = &v1.CustomResourceDefinition{
TypeMeta: metav1.TypeMeta{
APIVersion: "apiextensions.k8s.io/v1",
Kind: "CustomResourceDefinition",
},
ObjectMeta: metav1.ObjectMeta{
Name: "coolbar.stable.example.com",
},
Spec: v1.CustomResourceDefinitionSpec{
Group: "stable.example.com",
Names: v1.CustomResourceDefinitionNames{
Plural: "coolbars",
Singular: "coolbar",
ShortNames: []string{"bar"},
Kind: "CoolBar",
ListKind: "CoolBarList",
},
Scope: v1.ClusterScoped,
Versions: []v1.CustomResourceDefinitionVersion{
{
Name: "v1beta1",
Served: true,
Storage: true,
Deprecated: false,
Subresources: &v1.CustomResourceSubresources{
// This CRD has a /status subresource
Status: &v1.CustomResourceSubresourceStatus{},
},
Schema: &v1.CustomResourceValidation{
OpenAPIV3Schema: &v1.JSONSchemaProps{
Type: "object",
Properties: map[string]v1.JSONSchemaProps{"num": {Type: "integer", Description: "description"}},
},
},
},
{
Name: "v1",
Served: true,
Storage: true,
Deprecated: false,
Subresources: &v1.CustomResourceSubresources{
// This CRD has a /status subresource
Status: &v1.CustomResourceSubresourceStatus{},
},
Schema: &v1.CustomResourceValidation{
OpenAPIV3Schema: &v1.JSONSchemaProps{
Type: "object",
Properties: map[string]v1.JSONSchemaProps{"test": {Type: "integer", Description: "foo"}},
},
},
},
},
Conversion: &v1.CustomResourceConversion{},
},
Status: v1.CustomResourceDefinitionStatus{
Conditions: []v1.CustomResourceDefinitionCondition{
{
Type: v1.Established,
Status: v1.ConditionTrue,
},
},
},
}
type testEnv struct {
t *testing.T
clientset.Interface
mux *http.ServeMux
cleanFunc func()
runFunc func()
}
func setup(t *testing.T) (*testEnv, context.Context) {
env := &testEnv{
Interface: fake.NewSimpleClientset(),
t: t,
}
factory := externalversions.NewSharedInformerFactoryWithOptions(
env.Interface, 30*time.Second)
c := NewController(factory.Apiextensions().V1().CustomResourceDefinitions())
ctx, cancel := context.WithCancel(context.Background())
factory.Start(ctx.Done())
env.mux = http.NewServeMux()
h := handler.NewOpenAPIService(&spec.Swagger{})
h.RegisterOpenAPIVersionedService("/openapi/v2", env.mux)
stopCh := make(chan struct{})
env.runFunc = func() {
go c.Run(&spec.Swagger{
SwaggerProps: spec.SwaggerProps{
Paths: &spec.Paths{
Paths: map[string]spec.PathItem{
"/apis/apiextensions.k8s.io/v1": {},
},
},
},
}, h, stopCh)
}
env.cleanFunc = func() {
cancel()
close(stopCh)
}
return env, ctx
}
func (t *testEnv) pollForCondition(conditionFunc func(*spec.Swagger) bool) {
wait.Poll(time.Second*1, wait.ForeverTestTimeout, func() (bool, error) {
openapi := t.fetchOpenAPIOrDie()
if conditionFunc(openapi) {
return true, nil
}
return false, nil
})
}
func (t *testEnv) pollForPathExists(path string) {
wait.Poll(time.Second*1, wait.ForeverTestTimeout, func() (bool, error) {
openapi := t.fetchOpenAPIOrDie()
if _, ok := openapi.Paths.Paths[path]; !ok {
return false, nil
}
return true, nil
})
}
func (t *testEnv) pollForPathNotExists(path string) {
wait.Poll(time.Second*1, wait.ForeverTestTimeout, func() (bool, error) {
openapi := t.fetchOpenAPIOrDie()
if _, ok := openapi.Paths.Paths[path]; ok {
return false, nil
}
return true, nil
})
}
func (t *testEnv) fetchOpenAPIOrDie() *spec.Swagger {
server := httptest.NewServer(t.mux)
defer server.Close()
client := server.Client()
req, err := http.NewRequest("GET", server.URL+"/openapi/v2", nil)
if err != nil {
t.t.Error(err)
}
resp, err := client.Do(req)
if err != nil {
t.t.Error(err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.t.Error(err)
}
swagger := &spec.Swagger{}
if err := swagger.UnmarshalJSON(body); err != nil {
t.t.Error(err)
}
return swagger
}
func (t *testEnv) expectPath(swagger *spec.Swagger, path string) {
if _, ok := swagger.Paths.Paths[path]; !ok {
t.t.Errorf("Expected path %s to exist in OpenAPI", path)
}
}
func (t *testEnv) expectNoPath(swagger *spec.Swagger, path string) {
if _, ok := swagger.Paths.Paths[path]; ok {
t.t.Errorf("Expected path %s to not exist in OpenAPI", path)
}
}

View File

@ -0,0 +1,112 @@
/*
Copyright 2023 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 openapi
import (
"context"
"testing"
"time"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
"k8s.io/apiextensions-apiserver/test/integration/fixtures"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/dynamic"
kubernetes "k8s.io/client-go/kubernetes"
apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
"k8s.io/kubernetes/test/integration/framework"
"k8s.io/kube-openapi/pkg/validation/spec"
)
func TestOpenAPICRDGenerationNumber(t *testing.T) {
server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), nil, framework.SharedEtcd())
if err != nil {
t.Fatal(err)
}
defer server.TearDownFn()
config := server.ClientConfig
apiExtensionClient, err := clientset.NewForConfig(config)
if err != nil {
t.Fatal(err)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
t.Fatal(err)
}
dynamicClient, err := dynamic.NewForConfig(config)
if err != nil {
t.Fatal(err)
}
// Create a new CRD with group mygroup.example.com
crd := fixtures.NewRandomNameV1CustomResourceDefinition(apiextensionsv1.NamespaceScoped)
_, err = fixtures.CreateNewV1CustomResourceDefinition(crd, apiExtensionClient, dynamicClient)
if err != nil {
t.Fatal(err)
}
defer func() {
apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Delete(context.TODO(), crd.Name, metav1.DeleteOptions{})
}()
crd, err = apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Get(context.TODO(), crd.Name, metav1.GetOptions{})
if err != nil {
t.Fatal(err)
}
// Update OpenAPI schema and ensure it's reflected in the publishing
crd.Spec.Versions[0].Schema = &apiextensionsv1.CustomResourceValidation{
OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
Type: "object",
Properties: map[string]apiextensionsv1.JSONSchemaProps{"num": {Type: "integer", Description: "description"}},
},
}
updatedCRD, err := apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Update(context.TODO(), crd, metav1.UpdateOptions{})
if err != nil {
t.Fatal(err)
}
if updatedCRD.Generation <= crd.Generation {
t.Fatalf("Expected updated CRD to increment Generation counter. got preupdate: %d, postupdate %d", crd.Generation, updatedCRD.Generation)
}
err = wait.Poll(time.Second*1, wait.ForeverTestTimeout, func() (bool, error) {
body, err := clientset.RESTClient().Get().AbsPath("/openapi/v2").Do(context.TODO()).Raw()
if err != nil {
t.Fatal(err)
}
swagger := &spec.Swagger{}
if err := swagger.UnmarshalJSON(body); err != nil {
t.Error(err)
}
// Ensure that OpenAPI schema updated is reflected
if description := swagger.Definitions["com.example.mygroup.v1beta1."+crd.Spec.Names.Kind].Properties["num"].Description; description == "description" {
return true, nil
}
return false, nil
})
if err != nil {
t.Errorf("Expected description to be updated, err: %s", err)
}
}