diff --git a/pkg/kubectl/cmd/util/openapi/BUILD b/pkg/kubectl/cmd/util/openapi/BUILD index b7377d9fd43..b306d946a94 100644 --- a/pkg/kubectl/cmd/util/openapi/BUILD +++ b/pkg/kubectl/cmd/util/openapi/BUILD @@ -40,6 +40,7 @@ go_test( data = ["//api/openapi-spec:swagger-spec"], deps = [ ":go_default_library", + "//pkg/kubectl/cmd/util/openapi/testing:go_default_library", "//vendor/github.com/googleapis/gnostic/OpenAPIv2:go_default_library", "//vendor/github.com/googleapis/gnostic/compiler:go_default_library", "//vendor/github.com/onsi/ginkgo:go_default_library", @@ -60,7 +61,11 @@ filegroup( filegroup( name = "all-srcs", - srcs = [":package-srcs"], + srcs = [ + ":package-srcs", + "//pkg/kubectl/cmd/util/openapi/testing:all-srcs", + "//pkg/kubectl/cmd/util/openapi/validation:all-srcs", + ], tags = ["automanaged"], ) diff --git a/pkg/kubectl/cmd/util/openapi/document.go b/pkg/kubectl/cmd/util/openapi/document.go index 7456ae4aa33..23e83b3dda1 100644 --- a/pkg/kubectl/cmd/util/openapi/document.go +++ b/pkg/kubectl/cmd/util/openapi/document.go @@ -132,7 +132,8 @@ func NewOpenAPIData(doc *openapi_v2.Document) (Resources, error) { // Now, parse each model. We can validate that references exists. for _, namedSchema := range doc.GetDefinitions().GetAdditionalProperties() { - schema, err := definitions.ParseSchema(namedSchema.GetValue(), &Path{key: namedSchema.GetName()}) + path := NewPath(namedSchema.GetName()) + schema, err := definitions.ParseSchema(namedSchema.GetValue(), &path) if err != nil { return nil, err } @@ -252,7 +253,8 @@ func (d *Definitions) parseKind(s *openapi_v2.Schema, path *Path) (Schema, error for _, namedSchema := range s.GetProperties().GetAdditionalProperties() { var err error - fields[namedSchema.GetName()], err = d.ParseSchema(namedSchema.GetValue(), &Path{parent: path, key: namedSchema.GetName()}) + path := path.FieldPath(namedSchema.GetName()) + fields[namedSchema.GetName()], err = d.ParseSchema(namedSchema.GetValue(), &path) if err != nil { return nil, err } diff --git a/pkg/kubectl/cmd/util/openapi/openapi.go b/pkg/kubectl/cmd/util/openapi/openapi.go index 89e15de4d26..81f1f0eae5b 100644 --- a/pkg/kubectl/cmd/util/openapi/openapi.go +++ b/pkg/kubectl/cmd/util/openapi/openapi.go @@ -77,6 +77,10 @@ type Path struct { key string } +func NewPath(key string) Path { + return Path{key: key} +} + func (p *Path) Get() []string { if p == nil { return []string{} @@ -92,7 +96,23 @@ func (p *Path) Len() int { } func (p *Path) String() string { - return strings.Join(p.Get(), ".") + return strings.Join(p.Get(), "") +} + +// ArrayPath appends an array index and creates a new path +func (p *Path) ArrayPath(i int) Path { + return Path{ + parent: p, + key: fmt.Sprintf("[%d]", i), + } +} + +// FieldPath appends a field name and creates a new path +func (p *Path) FieldPath(field string) Path { + return Path{ + parent: p, + key: fmt.Sprintf(".%s", field), + } } // BaseSchema holds data used by each types of schema. diff --git a/pkg/kubectl/cmd/util/openapi/openapi_getter_test.go b/pkg/kubectl/cmd/util/openapi/openapi_getter_test.go index bbaca9dee35..46c5d3276e0 100644 --- a/pkg/kubectl/cmd/util/openapi/openapi_getter_test.go +++ b/pkg/kubectl/cmd/util/openapi/openapi_getter_test.go @@ -23,16 +23,17 @@ import ( . "github.com/onsi/gomega" "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi" + tst "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi/testing" ) var _ = Describe("Getting the Resources", func() { - var client *fakeOpenAPIClient + var client *tst.FakeClient var expectedData openapi.Resources var instance openapi.Getter BeforeEach(func() { - client = &fakeOpenAPIClient{} - d, err := data.OpenAPISchema() + client = tst.NewFakeClient(&fakeSchema) + d, err := fakeSchema.OpenAPISchema() Expect(err).To(BeNil()) expectedData, err = openapi.NewOpenAPIData(d) @@ -43,34 +44,34 @@ var _ = Describe("Getting the Resources", func() { Context("when the server returns a successful result", func() { It("should return the same data for multiple calls", func() { - Expect(client.calls).To(Equal(0)) + Expect(client.Calls).To(Equal(0)) result, err := instance.Get() Expect(err).To(BeNil()) Expect(result).To(Equal(expectedData)) - Expect(client.calls).To(Equal(1)) + Expect(client.Calls).To(Equal(1)) result, err = instance.Get() Expect(err).To(BeNil()) Expect(result).To(Equal(expectedData)) // No additional client calls expected - Expect(client.calls).To(Equal(1)) + Expect(client.Calls).To(Equal(1)) }) }) Context("when the server returns an unsuccessful result", func() { It("should return the same instance for multiple calls.", func() { - Expect(client.calls).To(Equal(0)) + Expect(client.Calls).To(Equal(0)) - client.err = fmt.Errorf("expected error") + client.Err = fmt.Errorf("expected error") _, err := instance.Get() - Expect(err).To(Equal(client.err)) - Expect(client.calls).To(Equal(1)) + Expect(err).To(Equal(client.Err)) + Expect(client.Calls).To(Equal(1)) _, err = instance.Get() - Expect(err).To(Equal(client.err)) + Expect(err).To(Equal(client.Err)) // No additional client calls expected - Expect(client.calls).To(Equal(1)) + Expect(client.Calls).To(Equal(1)) }) }) }) diff --git a/pkg/kubectl/cmd/util/openapi/openapi_test.go b/pkg/kubectl/cmd/util/openapi/openapi_test.go index 2d9ea5b97d6..e2eb2fa0c0d 100644 --- a/pkg/kubectl/cmd/util/openapi/openapi_test.go +++ b/pkg/kubectl/cmd/util/openapi/openapi_test.go @@ -17,17 +17,22 @@ limitations under the License. package openapi_test import ( + "path/filepath" + . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi" + tst "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi/testing" ) +var fakeSchema = tst.Fake{Path: filepath.Join("..", "..", "..", "..", "..", "api", "openapi-spec", "swagger.json")} + var _ = Describe("Reading apps/v1beta1/Deployment from openAPIData", func() { var resources openapi.Resources BeforeEach(func() { - s, err := data.OpenAPISchema() + s, err := fakeSchema.OpenAPISchema() Expect(err).To(BeNil()) resources, err = openapi.NewOpenAPIData(s) Expect(err).To(BeNil()) @@ -60,7 +65,7 @@ var _ = Describe("Reading apps/v1beta1/Deployment from openAPIData", func() { key := deployment.Fields["kind"].(*openapi.Primitive) Expect(key).ToNot(BeNil()) Expect(key.Type).To(Equal("string")) - Expect(key.GetPath().Get()).To(Equal([]string{"io.k8s.api.apps.v1beta1.Deployment", "kind"})) + Expect(key.GetPath().Get()).To(Equal([]string{"io.k8s.api.apps.v1beta1.Deployment", ".kind"})) }) It("should have a apiVersion key of type string", func() { @@ -68,7 +73,7 @@ var _ = Describe("Reading apps/v1beta1/Deployment from openAPIData", func() { key := deployment.Fields["apiVersion"].(*openapi.Primitive) Expect(key).ToNot(BeNil()) Expect(key.Type).To(Equal("string")) - Expect(key.GetPath().Get()).To(Equal([]string{"io.k8s.api.apps.v1beta1.Deployment", "apiVersion"})) + Expect(key.GetPath().Get()).To(Equal([]string{"io.k8s.api.apps.v1beta1.Deployment", ".apiVersion"})) }) It("should have a metadata key of type Reference", func() { @@ -136,7 +141,7 @@ var _ = Describe("Reading apps/v1beta1/Deployment from openAPIData", func() { var _ = Describe("Reading authorization.k8s.io/v1/SubjectAccessReview from openAPIData", func() { var resources openapi.Resources BeforeEach(func() { - s, err := data.OpenAPISchema() + s, err := fakeSchema.OpenAPISchema() Expect(err).To(BeNil()) resources, err = openapi.NewOpenAPIData(s) Expect(err).To(BeNil()) @@ -171,15 +176,43 @@ var _ = Describe("Reading authorization.k8s.io/v1/SubjectAccessReview from openA extra := sarspec.Fields["extra"].(*openapi.Map) Expect(extra).ToNot(BeNil()) Expect(extra.GetName()).To(Equal("Map of Array of string")) - Expect(extra.GetPath().Get()).To(Equal([]string{"io.k8s.api.authorization.v1.SubjectAccessReviewSpec", "extra"})) + Expect(extra.GetPath().Get()).To(Equal([]string{"io.k8s.api.authorization.v1.SubjectAccessReviewSpec", ".extra"})) array := extra.SubType.(*openapi.Array) Expect(array).ToNot(BeNil()) Expect(array.GetName()).To(Equal("Array of string")) - Expect(array.GetPath().Get()).To(Equal([]string{"io.k8s.api.authorization.v1.SubjectAccessReviewSpec", "extra"})) + Expect(array.GetPath().Get()).To(Equal([]string{"io.k8s.api.authorization.v1.SubjectAccessReviewSpec", ".extra"})) str := array.SubType.(*openapi.Primitive) Expect(str).ToNot(BeNil()) Expect(str.Type).To(Equal("string")) Expect(str.GetName()).To(Equal("string")) - Expect(str.GetPath().Get()).To(Equal([]string{"io.k8s.api.authorization.v1.SubjectAccessReviewSpec", "extra"})) + Expect(str.GetPath().Get()).To(Equal([]string{"io.k8s.api.authorization.v1.SubjectAccessReviewSpec", ".extra"})) + }) +}) + +var _ = Describe("Path", func() { + It("can be created by NewPath", func() { + path := openapi.NewPath("key") + Expect(path.String()).To(Equal("key")) + }) + It("can create and print complex paths", func() { + key := openapi.NewPath("key") + array := key.ArrayPath(12) + field := array.FieldPath("subKey") + + Expect(field.String()).To(Equal("key[12].subKey")) + }) + It("has a length", func() { + key := openapi.NewPath("key") + array := key.ArrayPath(12) + field := array.FieldPath("subKey") + + Expect(field.Len()).To(Equal(3)) + }) + It("can look like an array", func() { + key := openapi.NewPath("key") + array := key.ArrayPath(12) + field := array.FieldPath("subKey") + + Expect(field.Get()).To(Equal([]string{"key", "[12]", ".subKey"})) }) }) diff --git a/pkg/kubectl/cmd/util/openapi/testing/BUILD b/pkg/kubectl/cmd/util/openapi/testing/BUILD new file mode 100644 index 00000000000..9fed9f861b0 --- /dev/null +++ b/pkg/kubectl/cmd/util/openapi/testing/BUILD @@ -0,0 +1,32 @@ +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +load( + "@io_bazel_rules_go//go:def.bzl", + "go_library", +) + +go_library( + name = "go_default_library", + srcs = ["openapi.go"], + tags = ["automanaged"], + deps = [ + "//vendor/github.com/googleapis/gnostic/OpenAPIv2:go_default_library", + "//vendor/github.com/googleapis/gnostic/compiler:go_default_library", + "//vendor/gopkg.in/yaml.v2:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], +) diff --git a/pkg/kubectl/cmd/util/openapi/testing/openapi.go b/pkg/kubectl/cmd/util/openapi/testing/openapi.go new file mode 100644 index 00000000000..55554814b60 --- /dev/null +++ b/pkg/kubectl/cmd/util/openapi/testing/openapi.go @@ -0,0 +1,89 @@ +/* +Copyright 2017 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 testing + +import ( + "io/ioutil" + "os" + "sync" + + yaml "gopkg.in/yaml.v2" + + "github.com/googleapis/gnostic/OpenAPIv2" + "github.com/googleapis/gnostic/compiler" +) + +// Fake opens and returns a openapi swagger from a file Path. It will +// parse only once and then return the same copy everytime. +type Fake struct { + Path string + + once sync.Once + document *openapi_v2.Document + err error +} + +// OpenAPISchema returns the openapi document and a potential error. +func (f *Fake) OpenAPISchema() (*openapi_v2.Document, error) { + f.once.Do(func() { + _, err := os.Stat(f.Path) + if err != nil { + f.err = err + return + } + spec, err := ioutil.ReadFile(f.Path) + if err != nil { + f.err = err + return + } + var info yaml.MapSlice + err = yaml.Unmarshal(spec, &info) + if err != nil { + f.err = err + return + } + f.document, f.err = openapi_v2.NewDocument(info, compiler.NewContext("$root", nil)) + }) + return f.document, f.err +} + +// FakeClient implements a dummy OpenAPISchemaInterface that uses the +// fake OpenAPI schema given as a parameter, and count the number of +// call to the function. +type FakeClient struct { + Calls int + Err error + + fake *Fake +} + +// NewFakeClient creates a new FakeClient from the given Fake. +func NewFakeClient(f *Fake) *FakeClient { + return &FakeClient{fake: f} +} + +// OpenAPISchema returns a OpenAPI Document as returned by the fake, but +// it also counts the number of calls. +func (f *FakeClient) OpenAPISchema() (*openapi_v2.Document, error) { + f.Calls = f.Calls + 1 + + if f.Err != nil { + return nil, f.Err + } + + return f.fake.OpenAPISchema() +} diff --git a/pkg/kubectl/cmd/util/openapi/validation/BUILD b/pkg/kubectl/cmd/util/openapi/validation/BUILD new file mode 100644 index 00000000000..74eb5e97218 --- /dev/null +++ b/pkg/kubectl/cmd/util/openapi/validation/BUILD @@ -0,0 +1,60 @@ +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +load( + "@io_bazel_rules_go//go:def.bzl", + "go_library", + "go_test", +) + +go_library( + name = "go_default_library", + srcs = [ + "errors.go", + "types.go", + "validation.go", + ], + tags = ["automanaged"], + deps = [ + "//pkg/api/util:go_default_library", + "//pkg/kubectl/cmd/util/openapi:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/util/errors:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/util/json:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/util/yaml:go_default_library", + ], +) + +go_test( + name = "go_default_xtest", + srcs = [ + "validation_suite_test.go", + "validation_test.go", + ], + data = ["//api/openapi-spec:swagger-spec"], + tags = ["automanaged"], + deps = [ + ":go_default_library", + "//pkg/kubectl/cmd/util/openapi:go_default_library", + "//pkg/kubectl/cmd/util/openapi/testing:go_default_library", + "//vendor/github.com/onsi/ginkgo:go_default_library", + "//vendor/github.com/onsi/ginkgo/config:go_default_library", + "//vendor/github.com/onsi/ginkgo/types:go_default_library", + "//vendor/github.com/onsi/gomega:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/util/errors:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], +) diff --git a/pkg/kubectl/cmd/util/openapi/validation/errors.go b/pkg/kubectl/cmd/util/openapi/validation/errors.go new file mode 100644 index 00000000000..4f4ab9e3bd1 --- /dev/null +++ b/pkg/kubectl/cmd/util/openapi/validation/errors.go @@ -0,0 +1,79 @@ +/* +Copyright 2017 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 validation + +import ( + "fmt" +) + +type Errors struct { + errors []error +} + +func (e *Errors) Errors() []error { + return e.errors +} + +func (e *Errors) AppendErrors(err ...error) { + e.errors = append(e.errors, err...) +} + +type ValidationError struct { + Path string + Err error +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("ValidationError(%s): %v", e.Path, e.Err) +} + +type InvalidTypeError struct { + Path string + Expected string + Actual string +} + +func (e InvalidTypeError) Error() string { + return fmt.Sprintf("invalid type for %s: got %q, expected %q", e.Path, e.Actual, e.Expected) +} + +type MissingRequiredFieldError struct { + Path string + Field string +} + +func (e MissingRequiredFieldError) Error() string { + return fmt.Sprintf("missing required field %q in %s", e.Field, e.Path) +} + +type UnknownFieldError struct { + Path string + Field string +} + +func (e UnknownFieldError) Error() string { + return fmt.Sprintf("unknown field %q in %s", e.Field, e.Path) +} + +type InvalidObjectTypeError struct { + Path string + Type string +} + +func (e InvalidObjectTypeError) Error() string { + return fmt.Sprintf("unknown object type %q in %s", e.Type, e.Path) +} diff --git a/pkg/kubectl/cmd/util/openapi/validation/types.go b/pkg/kubectl/cmd/util/openapi/validation/types.go new file mode 100644 index 00000000000..e209930b4a9 --- /dev/null +++ b/pkg/kubectl/cmd/util/openapi/validation/types.go @@ -0,0 +1,266 @@ +/* +Copyright 2017 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 validation + +import ( + "reflect" + "sort" + + "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi" +) + +type ValidationItem interface { + openapi.SchemaVisitor + + Errors() []error + Path() *openapi.Path +} + +type baseItem struct { + errors Errors + path openapi.Path +} + +// Errors returns the list of errors found for this item. +func (item *baseItem) Errors() []error { + return item.errors.Errors() +} + +// AddValidationError wraps the given error into a ValidationError and +// attaches it to this item. +func (item *baseItem) AddValidationError(err error) { + item.errors.AppendErrors(ValidationError{Path: item.path.String(), Err: err}) +} + +// AddError adds a regular (non-validation related) error to the list. +func (item *baseItem) AddError(err error) { + item.errors.AppendErrors(err) +} + +// CopyErrors adds a list of errors to this item. This is useful to copy +// errors from subitems. +func (item *baseItem) CopyErrors(errs []error) { + item.errors.AppendErrors(errs...) +} + +// Path returns the path of this item, helps print useful errors. +func (item *baseItem) Path() *openapi.Path { + return &item.path +} + +// mapItem represents a map entry in the yaml. +type mapItem struct { + baseItem + + Map map[string]interface{} +} + +func (item *mapItem) sortedKeys() []string { + sortedKeys := []string{} + for key := range item.Map { + sortedKeys = append(sortedKeys, key) + } + sort.Strings(sortedKeys) + return sortedKeys +} + +var _ ValidationItem = &mapItem{} + +func (item *mapItem) VisitPrimitive(schema *openapi.Primitive) { + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: schema.Type, Actual: "map"}) +} + +func (item *mapItem) VisitArray(schema *openapi.Array) { + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: "array", Actual: "map"}) +} + +func (item *mapItem) VisitMap(schema *openapi.Map) { + for _, key := range item.sortedKeys() { + subItem, err := itemFactory(item.Path().FieldPath(key), item.Map[key]) + if err != nil { + item.AddError(err) + continue + } + schema.SubType.Accept(subItem) + item.CopyErrors(subItem.Errors()) + } +} + +func (item *mapItem) VisitKind(schema *openapi.Kind) { + // Verify each sub-field. + for _, key := range item.sortedKeys() { + subItem, err := itemFactory(item.Path().FieldPath(key), item.Map[key]) + if err != nil { + item.AddError(err) + continue + } + if _, ok := schema.Fields[key]; !ok { + item.AddValidationError(UnknownFieldError{Path: schema.GetPath().String(), Field: key}) + continue + } + schema.Fields[key].Accept(subItem) + item.CopyErrors(subItem.Errors()) + } + + // Verify that all required fields are present. + for _, required := range schema.RequiredFields { + if _, ok := item.Map[required]; !ok { + item.AddValidationError(MissingRequiredFieldError{Path: schema.GetPath().String(), Field: required}) + } + } +} + +// arrayItem represents a yaml array. +type arrayItem struct { + baseItem + + Array []interface{} +} + +var _ ValidationItem = &arrayItem{} + +func (item *arrayItem) VisitPrimitive(schema *openapi.Primitive) { + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: schema.Type, Actual: "array"}) +} + +func (item *arrayItem) VisitArray(schema *openapi.Array) { + for i, v := range item.Array { + subItem, err := itemFactory(item.Path().ArrayPath(i), v) + if err != nil { + item.AddError(err) + continue + } + schema.SubType.Accept(subItem) + item.CopyErrors(subItem.Errors()) + } +} + +func (item *arrayItem) VisitMap(schema *openapi.Map) { + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: "array", Actual: "map"}) +} + +func (item *arrayItem) VisitKind(schema *openapi.Kind) { + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: "array", Actual: "map"}) +} + +// primitiveItem represents a yaml value. +type primitiveItem struct { + baseItem + + Value interface{} + Kind string +} + +var _ ValidationItem = &primitiveItem{} + +func (item *primitiveItem) VisitPrimitive(schema *openapi.Primitive) { + // Some types of primitives can match more than one (a number + // can be a string, but not the other way around). Return from + // the switch if we have a valid possible type conversion + // NOTE(apelisse): This logic is blindly copied from the + // existing swagger logic, and I'm not sure I agree with it. + switch schema.Type { + case openapi.Boolean: + switch item.Kind { + case openapi.Boolean: + return + } + case openapi.Integer: + switch item.Kind { + case openapi.Integer, openapi.Number: + return + } + case openapi.Number: + switch item.Kind { + case openapi.Number: + return + } + case openapi.String: + return + } + + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: schema.Type, Actual: item.Kind}) +} + +func (item *primitiveItem) VisitArray(schema *openapi.Array) { + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: "array", Actual: item.Kind}) +} + +func (item *primitiveItem) VisitMap(schema *openapi.Map) { + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: "map", Actual: item.Kind}) +} + +func (item *primitiveItem) VisitKind(schema *openapi.Kind) { + item.AddValidationError(InvalidTypeError{Path: schema.GetPath().String(), Expected: "map", Actual: item.Kind}) +} + +// itemFactory creates the relevant item type/visitor based on the current yaml type. +func itemFactory(path openapi.Path, v interface{}) (ValidationItem, error) { + // We need to special case for no-type fields in yaml (e.g. empty item in list) + if v == nil { + return nil, InvalidObjectTypeError{Type: "nil", Path: path.String()} + } + kind := reflect.TypeOf(v).Kind() + switch kind { + case reflect.Bool: + return &primitiveItem{ + baseItem: baseItem{path: path}, + Value: v, + Kind: openapi.Boolean, + }, nil + case reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64: + return &primitiveItem{ + baseItem: baseItem{path: path}, + Value: v, + Kind: openapi.Integer, + }, nil + case reflect.Float32, + reflect.Float64: + return &primitiveItem{ + baseItem: baseItem{path: path}, + Value: v, + Kind: openapi.Number, + }, nil + case reflect.String: + return &primitiveItem{ + baseItem: baseItem{path: path}, + Value: v, + Kind: openapi.String, + }, nil + case reflect.Array, + reflect.Slice: + return &arrayItem{ + baseItem: baseItem{path: path}, + Array: v.([]interface{}), + }, nil + case reflect.Map: + return &mapItem{ + baseItem: baseItem{path: path}, + Map: v.(map[string]interface{}), + }, nil + } + return nil, InvalidObjectTypeError{Type: kind.String(), Path: path.String()} +} diff --git a/pkg/kubectl/cmd/util/openapi/validation/validation.go b/pkg/kubectl/cmd/util/openapi/validation/validation.go new file mode 100644 index 00000000000..ed5e255c8bf --- /dev/null +++ b/pkg/kubectl/cmd/util/openapi/validation/validation.go @@ -0,0 +1,103 @@ +/* +Copyright 2017 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 validation + +import ( + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/runtime/schema" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/yaml" + apiutil "k8s.io/kubernetes/pkg/api/util" + "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi" +) + +type SchemaValidation struct { + resources openapi.Resources +} + +func NewSchemaValidation(resources openapi.Resources) *SchemaValidation { + return &SchemaValidation{ + resources: resources, + } +} + +func (v *SchemaValidation) Validate(data []byte) error { + obj, err := parse(data) + if err != nil { + return err + } + + gvk, err := getObjectKind(obj) + if err != nil { + return err + } + + resource := v.resources.LookupResource(gvk) + if resource == nil { + return fmt.Errorf("unknown object type %q", gvk) + } + + rootValidation, err := itemFactory(openapi.NewPath(gvk.Kind), obj) + if err != nil { + return err + } + resource.Accept(rootValidation) + errs := rootValidation.Errors() + if errs != nil { + return utilerrors.NewAggregate(errs) + } + return nil +} + +func parse(data []byte) (interface{}, error) { + var obj interface{} + out, err := yaml.ToJSON(data) + if err != nil { + return nil, err + } + if err := json.Unmarshal(out, &obj); err != nil { + return nil, err + } + return obj, nil +} + +func getObjectKind(object interface{}) (schema.GroupVersionKind, error) { + fields := object.(map[string]interface{}) + if fields == nil { + return schema.GroupVersionKind{}, errors.New("invalid object to validate") + } + apiVersion := fields["apiVersion"] + if apiVersion == nil { + return schema.GroupVersionKind{}, errors.New("apiVersion not set") + } + if _, ok := apiVersion.(string); !ok { + return schema.GroupVersionKind{}, errors.New("apiVersion isn't string type") + } + version := apiutil.GetVersion(apiVersion.(string)) + kind := fields["kind"] + if kind == nil { + return schema.GroupVersionKind{}, errors.New("kind not set") + } + if _, ok := kind.(string); !ok { + return schema.GroupVersionKind{}, errors.New("kind isn't string type") + } + + return schema.GroupVersionKind{Kind: kind.(string), Version: version}, nil +} diff --git a/pkg/kubectl/cmd/util/openapi/validation/validation_suite_test.go b/pkg/kubectl/cmd/util/openapi/validation/validation_suite_test.go new file mode 100644 index 00000000000..bfc62c3ca29 --- /dev/null +++ b/pkg/kubectl/cmd/util/openapi/validation/validation_suite_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2017 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 validation_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/config" + . "github.com/onsi/ginkgo/types" + . "github.com/onsi/gomega" + + "fmt" + "testing" +) + +func TestOpenapi(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, "Openapi Suite", []Reporter{newlineReporter{}}) +} + +// Print a newline after the default newlineReporter due to issue +// https://github.com/jstemmer/go-junit-report/issues/31 +type newlineReporter struct{} + +func (newlineReporter) SpecSuiteWillBegin(config GinkgoConfigType, summary *SuiteSummary) {} + +func (newlineReporter) BeforeSuiteDidRun(setupSummary *SetupSummary) {} + +func (newlineReporter) AfterSuiteDidRun(setupSummary *SetupSummary) {} + +func (newlineReporter) SpecWillRun(specSummary *SpecSummary) {} + +func (newlineReporter) SpecDidComplete(specSummary *SpecSummary) {} + +// SpecSuiteDidEnd Prints a newline between "35 Passed | 0 Failed | 0 Pending | 0 Skipped" and "--- PASS:" +func (newlineReporter) SpecSuiteDidEnd(summary *SuiteSummary) { fmt.Printf("\n") } diff --git a/pkg/kubectl/cmd/util/openapi/validation/validation_test.go b/pkg/kubectl/cmd/util/openapi/validation/validation_test.go new file mode 100644 index 00000000000..cdcc0012f04 --- /dev/null +++ b/pkg/kubectl/cmd/util/openapi/validation/validation_test.go @@ -0,0 +1,222 @@ +/* +Copyright 2017 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 validation_test + +import ( + "path/filepath" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi" + tst "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi/testing" + "k8s.io/kubernetes/pkg/kubectl/cmd/util/openapi/validation" +) + +var fakeSchema = tst.Fake{Path: filepath.Join("..", "..", "..", "..", "..", "..", "api", "openapi-spec", "swagger.json")} + +var _ = Describe("resource validation using OpenAPI Schema", func() { + var validator *validation.SchemaValidation + BeforeEach(func() { + s, err := fakeSchema.OpenAPISchema() + Expect(err).To(BeNil()) + resources, err := openapi.NewOpenAPIData(s) + Expect(err).To(BeNil()) + validator = validation.NewSchemaValidation(resources) + Expect(validator).ToNot(BeNil()) + }) + + It("validates a valid pod", func() { + err := validator.Validate([]byte(` +apiVersion: v1 +kind: Pod +metadata: + labels: + name: redis-master + name: name +spec: + containers: + - args: + - this + - is + - an + - ok + - command + image: gcr.io/fake_project/fake_image:fake_tag + name: master +`)) + Expect(err).To(BeNil()) + }) + + It("finds invalid command (string instead of []string) in Json Pod", func() { + err := validator.Validate([]byte(` +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "name", + "labels": { + "name": "redis-master" + } + }, + "spec": { + "containers": [ + { + "name": "master", + "image": "gcr.io/fake_project/fake_image:fake_tag", + "args": "this is a bad command" + } + ] + } +} +`)) + Expect(err).To(Equal(utilerrors.NewAggregate([]error{ + validation.ValidationError{ + Path: "Pod.spec.containers[0].args", + Err: validation.InvalidTypeError{ + Path: "io.k8s.api.core.v1.Container.args", + Expected: "array", + Actual: "string", + }, + }, + }))) + }) + + It("fails because hostPort is string instead of int", func() { + err := validator.Validate([]byte(` +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "apache-php", + "labels": { + "name": "apache-php" + } + }, + "spec": { + "volumes": [{ + "name": "shared-disk" + }], + "containers": [ + { + "name": "apache-php", + "image": "gcr.io/fake_project/fake_image:fake_tag", + "ports": [ + { + "name": "apache", + "hostPort": "13380", + "containerPort": 80, + "protocol": "TCP" + } + ], + "volumeMounts": [ + { + "name": "shared-disk", + "mountPath": "/var/www/html" + } + ] + } + ] + } +} +`)) + + Expect(err).To(Equal(utilerrors.NewAggregate([]error{ + validation.ValidationError{ + Path: "Pod.spec.containers[0].ports[0].hostPort", + Err: validation.InvalidTypeError{ + Path: "io.k8s.api.core.v1.ContainerPort.hostPort", + Expected: "integer", + Actual: "string", + }, + }, + }))) + + }) + + It("fails because volume is not an array of object", func() { + err := validator.Validate([]byte(` +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "apache-php", + "labels": { + "name": "apache-php" + } + }, + "spec": { + "volumes": [ + "name": "shared-disk" + ], + "containers": [ + { + "name": "apache-php", + "image": "gcr.io/fake_project/fake_image:fake_tag", + "ports": [ + { + "name": "apache", + "hostPort": 13380, + "containerPort": 80, + "protocol": "TCP" + } + ], + "volumeMounts": [ + { + "name": "shared-disk", + "mountPath": "/var/www/html" + } + ] + } + ] + } +} +`)) + Expect(err.Error()).To(Equal("invalid character ':' after array element")) + }) + + It("fails because some string lists have empty strings", func() { + err := validator.Validate([]byte(` +apiVersion: v1 +kind: Pod +metadata: + labels: + name: redis-master + name: name +spec: + containers: + - image: gcr.io/fake_project/fake_image:fake_tag + name: master + args: + - + command: + - +`)) + + Expect(err).To(Equal(utilerrors.NewAggregate([]error{ + validation.InvalidObjectTypeError{ + Path: "Pod.spec.containers[0].args[0]", + Type: "nil", + }, + validation.InvalidObjectTypeError{ + Path: "Pod.spec.containers[0].command[0]", + Type: "nil", + }, + }))) + }) +})