diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/algorithm.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/algorithm.go new file mode 100644 index 00000000000..ab7c73c98ee --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/algorithm.go @@ -0,0 +1,99 @@ +/* +Copyright 2019 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 objectmeta + +import ( + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// Coerce checks types of embedded ObjectMeta and TypeMeta and prunes unknown fields inside the former. +// It does coerce ObjectMeta and TypeMeta at the root if includeRoot is true. +// If dropInvalidFields is true, fields of wrong type will be dropped. +func Coerce(pth *field.Path, obj interface{}, s *structuralschema.Structural, includeRoot, dropInvalidFields bool) *field.Error { + if includeRoot { + if s == nil { + s = &structuralschema.Structural{} + } + clone := *s + clone.XEmbeddedResource = true + s = &clone + } + c := coercer{dropInvalidFields: dropInvalidFields} + return c.coerce(pth, obj, s) +} + +type coercer struct { + dropInvalidFields bool +} + +func (c *coercer) coerce(pth *field.Path, x interface{}, s *structuralschema.Structural) *field.Error { + if s == nil { + return nil + } + switch x := x.(type) { + case map[string]interface{}: + for k, v := range x { + if s.XEmbeddedResource { + switch k { + case "apiVersion", "kind": + if _, ok := v.(string); !ok && c.dropInvalidFields { + delete(x, k) + } else if !ok { + return field.Invalid(pth, v, "must be a string") + } + case "metadata": + meta, found, err := GetObjectMeta(x, c.dropInvalidFields) + if err != nil { + if !c.dropInvalidFields { + return field.Invalid(pth.Child("metadata"), v, err.Error()) + } + // pass through on error if dropInvalidFields is true + } else if found { + if err := SetObjectMeta(x, meta); err != nil { + return field.Invalid(pth.Child("metadata"), v, err.Error()) + } + if meta.CreationTimestamp.IsZero() { + unstructured.RemoveNestedField(x, "metadata", "creationTimestamp") + } + } + } + } + prop, ok := s.Properties[k] + if ok { + if err := c.coerce(pth.Child(k), v, &prop); err != nil { + return err + } + } else if s.AdditionalProperties != nil { + if err := c.coerce(pth.Key(k), v, s.AdditionalProperties.Structural); err != nil { + return err + } + } + } + case []interface{}: + for i, v := range x { + if err := c.coerce(pth.Index(i), v, s.Items); err != nil { + return err + } + } + default: + // scalars, do nothing + } + + return nil +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/algorithm_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/algorithm_test.go new file mode 100644 index 00000000000..326a97d396d --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/algorithm_test.go @@ -0,0 +1,515 @@ +/* +Copyright 2019 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 objectmeta + +import ( + "bytes" + "reflect" + "testing" + + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apimachinery/pkg/util/diff" + "k8s.io/apimachinery/pkg/util/json" +) + +func TestCoerce(t *testing.T) { + tests := []struct { + name string + json string + includeRoot bool + dropInvalidFields bool + schema *structuralschema.Structural + expected string + expectedError bool + }{ + {name: "empty", json: "null", schema: nil, expected: "null"}, + {name: "scalar", json: "4", schema: &structuralschema.Structural{}, expected: "4"}, + {name: "scalar array", json: "[1,2]", schema: &structuralschema.Structural{ + Items: &structuralschema.Structural{}, + }, expected: "[1,2]"}, + {name: "x-kubernetes-embedded-resource", json: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "unspecified":"bar", + "pruned": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "spec": { + "unspecified": "bar" + } + }, + "preserving": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "spec": { + "unspecified": "bar" + } + }, + "nested": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "spec": { + "unspecified": "bar", + "embedded": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "spec": { + "unspecified": "bar" + } + } + } + } +} +`, schema: &structuralschema.Structural{ + Generic: structuralschema.Generic{Type: "object"}, + Properties: map[string]structuralschema.Structural{ + "pruned": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + Properties: map[string]structuralschema.Structural{ + "spec": { + Generic: structuralschema.Generic{Type: "object"}, + }, + }, + }, + "preserving": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + XPreserveUnknownFields: true, + }, + }, + "nested": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + Properties: map[string]structuralschema.Structural{ + "spec": { + Generic: structuralschema.Generic{Type: "object"}, + Properties: map[string]structuralschema.Structural{ + "embedded": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + Properties: map[string]structuralschema.Structural{ + "spec": { + Generic: structuralschema.Generic{Type: "object"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, expected: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "unspecified":"bar", + "pruned": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance" + }, + "spec": { + "unspecified": "bar" + } + }, + "preserving": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance" + }, + "spec": { + "unspecified": "bar" + } + }, + "nested": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance" + }, + "spec": { + "unspecified": "bar", + "embedded": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance" + }, + "spec": { + "unspecified": "bar" + } + } + } + } +} +`}, + {name: "x-kubernetes-embedded-resource, with includeRoot=true", json: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "unspecified":"bar", + "pruned": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "spec": { + "unspecified": "bar" + } + }, + "preserving": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "spec": { + "unspecified": "bar" + } + }, + "nested": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "spec": { + "unspecified": "bar", + "embedded": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance", + "unspecified": "bar" + }, + "spec": { + "unspecified": "bar" + } + } + } + } +} +`, includeRoot: true, schema: &structuralschema.Structural{ + Generic: structuralschema.Generic{Type: "object"}, + Properties: map[string]structuralschema.Structural{ + "pruned": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + Properties: map[string]structuralschema.Structural{ + "spec": { + Generic: structuralschema.Generic{Type: "object"}, + }, + }, + }, + "preserving": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + XPreserveUnknownFields: true, + }, + }, + "nested": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + Properties: map[string]structuralschema.Structural{ + "spec": { + Generic: structuralschema.Generic{Type: "object"}, + Properties: map[string]structuralschema.Structural{ + "embedded": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + Properties: map[string]structuralschema.Structural{ + "spec": { + Generic: structuralschema.Generic{Type: "object"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, expected: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance" + }, + "unspecified":"bar", + "pruned": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance" + }, + "spec": { + "unspecified": "bar" + } + }, + "preserving": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance" + }, + "spec": { + "unspecified": "bar" + } + }, + "nested": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance" + }, + "spec": { + "unspecified": "bar", + "embedded": { + "apiVersion": "foo/v1", + "kind": "Foo", + "unspecified": "bar", + "metadata": { + "name": "instance" + }, + "spec": { + "unspecified": "bar" + } + } + } + } +} +`}, + {name: "without name", json: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance" + }, + "pruned": { + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "namespace": "kube-system" + } + } +} +`, schema: &structuralschema.Structural{ + Generic: structuralschema.Generic{Type: "object"}, + Properties: map[string]structuralschema.Structural{ + "pruned": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + }, + }, + }, expected: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance" + }, + "pruned": { + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "namespace": "kube-system" + } + } +} +`}, + {name: "x-kubernetes-embedded-resource, with dropInvalidFields=true", json: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance" + }, + "pruned": { + "apiVersion": 42, + "kind": 42, + "metadata": { + "name": "instance", + "namespace": ["abc"], + "labels": { + "foo": 42 + } + } + } +} +`, dropInvalidFields: true, schema: &structuralschema.Structural{ + Generic: structuralschema.Generic{Type: "object"}, + Properties: map[string]structuralschema.Structural{ + "pruned": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + Properties: map[string]structuralschema.Structural{ + "spec": { + Generic: structuralschema.Generic{Type: "object"}, + }, + }, + }, + }, + }, expected: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance" + }, + "pruned": { + "metadata": { + "name": "instance" + } + } +} +`}, + {name: "invalid metadata type, with dropInvalidFields=true", json: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance" + }, + "pruned": { + "apiVersion": 42, + "kind": 42, + "metadata": [42] + } +} +`, dropInvalidFields: true, schema: &structuralschema.Structural{ + Generic: structuralschema.Generic{Type: "object"}, + Properties: map[string]structuralschema.Structural{ + "pruned": { + Generic: structuralschema.Generic{Type: "object"}, + Extensions: structuralschema.Extensions{ + XEmbeddedResource: true, + }, + }, + }, + }, expected: ` +{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": { + "name": "instance" + }, + "pruned": { + "metadata": [42] + } +} +`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in interface{} + if err := json.Unmarshal([]byte(tt.json), &in); err != nil { + t.Fatal(err) + } + + var expected interface{} + if err := json.Unmarshal([]byte(tt.expected), &expected); err != nil { + t.Fatal(err) + } + + err := Coerce(nil, in, tt.schema, tt.includeRoot, tt.dropInvalidFields) + if tt.expectedError && err == nil { + t.Error("expected error, but did not get any") + } else if !tt.expectedError && err != nil { + t.Errorf("expected no error, but got: %v", err) + } else if !reflect.DeepEqual(in, expected) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + err := enc.Encode(in) + if err != nil { + t.Fatalf("unexpected result mashalling error: %v", err) + } + t.Errorf("expected: %s\ngot: %s\ndiff: %s", tt.expected, buf.String(), diff.ObjectDiff(expected, in)) + } + }) + } +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/coerce.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/coerce.go index 6a2932dc325..08f52ce1519 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/coerce.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/coerce.go @@ -27,13 +27,16 @@ import ( var encodingjson = json.CaseSensitiveJsonIterator() -func GetObjectMeta(u *unstructured.Unstructured, dropMalformedFields bool) (*metav1.ObjectMeta, bool, error) { - metadata, found := u.UnstructuredContent()["metadata"] +// GetObjectMeta does conversion of JSON to ObjectMeta. It first tries json.Unmarshal into a metav1.ObjectMeta +// type. If that does not work and dropMalformedFields is true, it does field-by-field best-effort conversion +// throwing away fields which lead to errors. +func GetObjectMeta(obj map[string]interface{}, dropMalformedFields bool) (*metav1.ObjectMeta, bool, error) { + metadata, found := obj["metadata"] if !found { return nil, false, nil } - // round-trip through JSON first, hoping that unmarshaling just works + // round-trip through JSON first, hoping that unmarshalling just works objectMeta := &metav1.ObjectMeta{} metadataBytes, err := encodingjson.Marshal(metadata) if err != nil { @@ -72,9 +75,10 @@ func GetObjectMeta(u *unstructured.Unstructured, dropMalformedFields bool) (*met return accumulatedObjectMeta, true, nil } -func SetObjectMeta(u *unstructured.Unstructured, objectMeta *metav1.ObjectMeta) error { +// SetObjectMeta writes back ObjectMeta into a JSON data structure. +func SetObjectMeta(obj map[string]interface{}, objectMeta *metav1.ObjectMeta) error { if objectMeta == nil { - unstructured.RemoveNestedField(u.UnstructuredContent(), "metadata") + unstructured.RemoveNestedField(obj, "metadata") return nil } @@ -83,6 +87,6 @@ func SetObjectMeta(u *unstructured.Unstructured, objectMeta *metav1.ObjectMeta) return err } - u.UnstructuredContent()["metadata"] = metadata + obj["metadata"] = metadata return nil } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/coerce_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/coerce_test.go index 41bb2caabdd..fcbb9fde0f6 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/coerce_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/coerce_test.go @@ -46,10 +46,10 @@ func TestRoundtripObjectMeta(t *testing.T) { u := &unstructured.Unstructured{Object: map[string]interface{}{}} original := &metav1.ObjectMeta{} fuzzer.Fuzz(original) - if err := SetObjectMeta(u, original); err != nil { + if err := SetObjectMeta(u.Object, original); err != nil { t.Fatalf("unexpected error setting ObjectMeta: %v", err) } - o, _, err := GetObjectMeta(u, false) + o, _, err := GetObjectMeta(u.Object, false) if err != nil { t.Fatalf("unexpected error getting the Objectmeta: %v", err) } @@ -96,7 +96,7 @@ func TestMalformedObjectMetaFields(t *testing.T) { for _, pth := range jsonPaths(nil, goodMetaMap) { for _, v := range spuriousValues() { // skip values of same type, because they can only cause decoding errors further insides - orig, err := JsonPathValue(goodMetaMap, pth, 0) + orig, err := JSONPathValue(goodMetaMap, pth, 0) if err != nil { t.Fatalf("unexpected to not find something at %v: %v", pth, err) } @@ -109,7 +109,7 @@ func TestMalformedObjectMetaFields(t *testing.T) { if err != nil { t.Fatal(err) } - if err := SetJsonPath(spuriousMetaMap, pth, 0, v); err != nil { + if err := SetJSONPath(spuriousMetaMap, pth, 0, v); err != nil { t.Fatal(err) } @@ -130,7 +130,7 @@ func TestMalformedObjectMetaFields(t *testing.T) { switch { default: // delete complete top-level field by default - DeleteJsonPath(truncatedMetaMap, pth[:1], 0) + DeleteJSONPath(truncatedMetaMap, pth[:1], 0) } truncatedJSON, err := encodingjson.Marshal(truncatedMetaMap) @@ -145,7 +145,7 @@ func TestMalformedObjectMetaFields(t *testing.T) { // make sure dropInvalidTypedFields+getObjectMeta matches what we expect u := &unstructured.Unstructured{Object: map[string]interface{}{"metadata": spuriousMetaMap}} - actualObjectMeta, _, err := GetObjectMeta(u, true) + actualObjectMeta, _, err := GetObjectMeta(u.Object, true) if err != nil { t.Errorf("got unexpected error after dropping invalid typed fields on %v=%#v: %v", pth, v, err) continue @@ -174,7 +174,7 @@ func TestGetObjectMetaNils(t *testing.T) { }, } - o, _, err := GetObjectMeta(u, true) + o, _, err := GetObjectMeta(u.Object, true) if err != nil { t.Fatal(err) } @@ -223,7 +223,7 @@ func TestGetObjectMeta(t *testing.T) { }, }} - meta, _, err := GetObjectMeta(u, true) + meta, _, err := GetObjectMeta(u.Object, true) if err != nil { t.Fatal(err) } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/jsonpath_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/jsonpath_test.go index f6aa74743de..d48e7e36a8c 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/jsonpath_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/jsonpath_test.go @@ -28,10 +28,10 @@ type ( index *int field string } - JsonPath []jsonPathNode + JSONPath []jsonPathNode ) -func (p JsonPath) String() string { +func (p JSONPath) String() string { var buf bytes.Buffer for _, n := range p { if n.index == nil { @@ -43,8 +43,8 @@ func (p JsonPath) String() string { return buf.String() } -func jsonPaths(base JsonPath, j map[string]interface{}) []JsonPath { - res := make([]JsonPath, 0, len(j)) +func jsonPaths(base JSONPath, j map[string]interface{}) []JSONPath { + res := make([]JSONPath, 0, len(j)) for k, old := range j { kPth := append(append([]jsonPathNode(nil), base...), jsonPathNode{field: k}) res = append(res, kPth) @@ -59,8 +59,8 @@ func jsonPaths(base JsonPath, j map[string]interface{}) []JsonPath { return res } -func jsonIterSlice(base JsonPath, j []interface{}) []JsonPath { - res := make([]JsonPath, 0, len(j)) +func jsonIterSlice(base JSONPath, j []interface{}) []JSONPath { + res := make([]JSONPath, 0, len(j)) for i, old := range j { index := i iPth := append(append([]jsonPathNode(nil), base...), jsonPathNode{index: &index}) @@ -76,7 +76,7 @@ func jsonIterSlice(base JsonPath, j []interface{}) []JsonPath { return res } -func JsonPathValue(j map[string]interface{}, pth JsonPath, base int) (interface{}, error) { +func JSONPathValue(j map[string]interface{}, pth JSONPath, base int) (interface{}, error) { if len(pth) == base { return nil, fmt.Errorf("empty json path is invalid for object") } @@ -92,7 +92,7 @@ func JsonPathValue(j map[string]interface{}, pth JsonPath, base int) (interface{ } switch field := field.(type) { case map[string]interface{}: - return JsonPathValue(field, pth, base+1) + return JSONPathValue(field, pth, base+1) case []interface{}: return jsonPathValueSlice(field, pth, base+1) default: @@ -100,7 +100,7 @@ func JsonPathValue(j map[string]interface{}, pth JsonPath, base int) (interface{ } } -func jsonPathValueSlice(j []interface{}, pth JsonPath, base int) (interface{}, error) { +func jsonPathValueSlice(j []interface{}, pth JSONPath, base int) (interface{}, error) { if len(pth) == base { return nil, fmt.Errorf("empty json path %q is invalid for object", pth) } @@ -115,7 +115,7 @@ func jsonPathValueSlice(j []interface{}, pth JsonPath, base int) (interface{}, e } switch item := j[*pth[base].index].(type) { case map[string]interface{}: - return JsonPathValue(item, pth, base+1) + return JSONPathValue(item, pth, base+1) case []interface{}: return jsonPathValueSlice(item, pth, base+1) default: @@ -123,7 +123,7 @@ func jsonPathValueSlice(j []interface{}, pth JsonPath, base int) (interface{}, e } } -func SetJsonPath(j map[string]interface{}, pth JsonPath, base int, value interface{}) error { +func SetJSONPath(j map[string]interface{}, pth JSONPath, base int, value interface{}) error { if len(pth) == base { return fmt.Errorf("empty json path is invalid for object") } @@ -140,15 +140,15 @@ func SetJsonPath(j map[string]interface{}, pth JsonPath, base int, value interfa } switch field := field.(type) { case map[string]interface{}: - return SetJsonPath(field, pth, base+1, value) + return SetJSONPath(field, pth, base+1, value) case []interface{}: - return setJsonPathSlice(field, pth, base+1, value) + return setJSONPathSlice(field, pth, base+1, value) default: return fmt.Errorf("invalid non-terminal json path %q for field", pth[:base+1]) } } -func setJsonPathSlice(j []interface{}, pth JsonPath, base int, value interface{}) error { +func setJSONPathSlice(j []interface{}, pth JSONPath, base int, value interface{}) error { if len(pth) == base { return fmt.Errorf("empty json path %q is invalid for object", pth) } @@ -164,15 +164,15 @@ func setJsonPathSlice(j []interface{}, pth JsonPath, base int, value interface{} } switch item := j[*pth[base].index].(type) { case map[string]interface{}: - return SetJsonPath(item, pth, base+1, value) + return SetJSONPath(item, pth, base+1, value) case []interface{}: - return setJsonPathSlice(item, pth, base+1, value) + return setJSONPathSlice(item, pth, base+1, value) default: return fmt.Errorf("invalid non-terminal json path %q for index", pth[:base+1]) } } -func DeleteJsonPath(j map[string]interface{}, pth JsonPath, base int) error { +func DeleteJSONPath(j map[string]interface{}, pth JSONPath, base int) error { if len(pth) == base { return fmt.Errorf("empty json path is invalid for object") } @@ -189,7 +189,7 @@ func DeleteJsonPath(j map[string]interface{}, pth JsonPath, base int) error { } switch field := field.(type) { case map[string]interface{}: - return DeleteJsonPath(field, pth, base+1) + return DeleteJSONPath(field, pth, base+1) case []interface{}: if len(pth) == base+2 { if pth[base+1].index == nil { @@ -198,13 +198,13 @@ func DeleteJsonPath(j map[string]interface{}, pth JsonPath, base int) error { j[pth[base].field] = append(field[:*pth[base+1].index], field[*pth[base+1].index+1:]...) return nil } - return deleteJsonPathSlice(field, pth, base+1) + return deleteJSONPathSlice(field, pth, base+1) default: return fmt.Errorf("invalid non-terminal json path %q for field", pth[:base+1]) } } -func deleteJsonPathSlice(j []interface{}, pth JsonPath, base int) error { +func deleteJSONPathSlice(j []interface{}, pth JSONPath, base int) error { if len(pth) == base { return fmt.Errorf("empty json path %q is invalid for object", pth) } @@ -219,7 +219,7 @@ func deleteJsonPathSlice(j []interface{}, pth JsonPath, base int) error { } switch item := j[*pth[base].index].(type) { case map[string]interface{}: - return DeleteJsonPath(item, pth, base+1) + return DeleteJSONPath(item, pth, base+1) case []interface{}: if len(pth) == base+2 { if pth[base+1].index == nil { @@ -228,7 +228,7 @@ func deleteJsonPathSlice(j []interface{}, pth JsonPath, base int) error { j[*pth[base].index] = append(item[:*pth[base+1].index], item[*pth[base+1].index+1:]) return nil } - return deleteJsonPathSlice(item, pth, base+1) + return deleteJSONPathSlice(item, pth, base+1) default: return fmt.Errorf("invalid non-terminal json path %q for index", pth[:base+1]) } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/validation.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/validation.go new file mode 100644 index 00000000000..5e788109f90 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/validation.go @@ -0,0 +1,119 @@ +/* +Copyright 2019 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 objectmeta + +import ( + "strings" + + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + metavalidation "k8s.io/apimachinery/pkg/api/validation" + "k8s.io/apimachinery/pkg/api/validation/path" + "k8s.io/apimachinery/pkg/runtime/schema" + utilvalidation "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// Validate validates embedded ObjectMeta and TypeMeta. +// It also validate those at the root if includeRoot is true. +func Validate(pth *field.Path, obj interface{}, s *structuralschema.Structural, includeRoot bool) field.ErrorList { + if includeRoot { + if s == nil { + s = &structuralschema.Structural{} + } + clone := *s + clone.XEmbeddedResource = true + s = &clone + } + return validate(pth, obj, s) +} + +func validate(pth *field.Path, x interface{}, s *structuralschema.Structural) field.ErrorList { + if s == nil { + return nil + } + + var allErrs field.ErrorList + + switch x := x.(type) { + case map[string]interface{}: + if s.XEmbeddedResource { + allErrs = append(allErrs, validateEmbeddedResource(pth, x, s)...) + } + + for k, v := range x { + prop, ok := s.Properties[k] + if ok { + allErrs = append(allErrs, validate(pth.Child(k), v, &prop)...) + } else if s.AdditionalProperties != nil { + allErrs = append(allErrs, validate(pth.Key(k), v, s.AdditionalProperties.Structural)...) + } + } + case []interface{}: + for i, v := range x { + allErrs = append(allErrs, validate(pth.Index(i), v, s.Items)...) + } + default: + // scalars, do nothing + } + + return allErrs +} + +func validateEmbeddedResource(pth *field.Path, x map[string]interface{}, s *structuralschema.Structural) field.ErrorList { + var allErrs field.ErrorList + + // require apiVersion and kind, but not metadata + if _, found := x["apiVersion"]; !found { + allErrs = append(allErrs, field.Required(pth.Child("apiVersion"), "must not be empty")) + } + if _, found := x["kind"]; !found { + allErrs = append(allErrs, field.Required(pth.Child("kind"), "must not be empty")) + } + + for k, v := range x { + switch k { + case "apiVersion": + if apiVersion, ok := v.(string); !ok { + allErrs = append(allErrs, field.Invalid(pth.Child("apiVersion"), v, "must be a string")) + } else if len(apiVersion) == 0 { + allErrs = append(allErrs, field.Invalid(pth.Child("apiVersion"), apiVersion, "must not be empty")) + } else if _, err := schema.ParseGroupVersion(apiVersion); err != nil { + allErrs = append(allErrs, field.Invalid(pth.Child("apiVersion"), apiVersion, err.Error())) + } + case "kind": + if kind, ok := v.(string); !ok { + allErrs = append(allErrs, field.Invalid(pth.Child("kind"), v, "must be a string")) + } else if len(kind) == 0 { + allErrs = append(allErrs, field.Invalid(pth.Child("kind"), kind, "must not be empty")) + } else if errs := utilvalidation.IsDNS1035Label(strings.ToLower(kind)); len(errs) > 0 { + allErrs = append(allErrs, field.Invalid(pth.Child("kind"), kind, "may have mixed case, but should otherwise match: "+strings.Join(errs, ","))) + } + case "metadata": + meta, _, err := GetObjectMeta(x, false) + if err != nil { + allErrs = append(allErrs, field.Invalid(pth.Child("metadata"), v, err.Error())) + } else { + if len(meta.Name) == 0 { + meta.Name = "fakename" // we have to set something to avoid an error + } + allErrs = append(allErrs, metavalidation.ValidateObjectMeta(meta, len(meta.Namespace) > 0, path.ValidatePathSegmentName, pth.Child("metadata"))...) + } + } + } + + return allErrs +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/validation_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/validation_test.go new file mode 100644 index 00000000000..3ac225ff804 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta/validation_test.go @@ -0,0 +1,215 @@ +/* +Copyright 2019 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 objectmeta + +import ( + "testing" + + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestValidateEmbeddedResource(t *testing.T) { + tests := []struct { + name string + object map[string]interface{} + errors []validationMatch + }{ + {name: "empty", object: map[string]interface{}{}, errors: []validationMatch{ + required("apiVersion"), + required("kind"), + }}, + {name: "version and kind", object: map[string]interface{}{ + "apiVersion": "foo/v1", + "kind": "Foo", + }}, + {name: "invalid kind", object: map[string]interface{}{ + "apiVersion": "foo/v1", + "kind": "foo.bar-com", + }, errors: []validationMatch{ + invalid("kind"), + }}, + {name: "no name", object: map[string]interface{}{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": map[string]interface{}{ + "namespace": "kube-system", + }, + }}, + {name: "no namespace", object: map[string]interface{}{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": map[string]interface{}{ + "name": "foo", + }, + }}, + {name: "invalid", object: map[string]interface{}{ + "apiVersion": "foo/v1", + "kind": "Foo", + "metadata": map[string]interface{}{ + "name": "..", + "namespace": "$$$", + "labels": map[string]interface{}{ + "#": "#", + }, + "annotations": map[string]interface{}{ + "#": "#", + }, + }, + }, errors: []validationMatch{ + invalid("metadata", "name"), + invalid("metadata", "namespace"), + invalid("metadata", "labels"), // key + invalid("metadata", "labels"), // value + invalid("metadata", "annotations"), // key + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema := &structuralschema.Structural{Extensions: structuralschema.Extensions{XEmbeddedResource: true}} + errs := validateEmbeddedResource(nil, tt.object, schema) + seenErrs := make([]bool, len(errs)) + + for _, expectedError := range tt.errors { + found := false + for i, err := range errs { + if expectedError.matches(err) && !seenErrs[i] { + found = true + seenErrs[i] = true + break + } + } + + if !found { + t.Errorf("expected %v at %v, got %v", expectedError.errorType, expectedError.path.String(), errs) + } + } + + for i, seen := range seenErrs { + if !seen { + t.Errorf("unexpected error: %v", errs[i]) + } + } + }) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + object string + includeRoot bool + errors []validationMatch + }{ + {name: "empty", object: `{}`, errors: []validationMatch{}}, + {name: "include root", object: `{}`, includeRoot: true, errors: []validationMatch{ + required("apiVersion"), + required("kind"), + }}, + {name: "embedded", object: ` +{ + "embedded": {} +}`, errors: []validationMatch{ + required("embedded", "apiVersion"), + required("embedded", "kind"), + }}, + {name: "nested", object: ` +{ + "nested": { + "embedded": {} + } +}`, errors: []validationMatch{ + required("nested", "apiVersion"), + required("nested", "kind"), + required("nested", "embedded", "apiVersion"), + required("nested", "embedded", "kind"), + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema := &structuralschema.Structural{ + Properties: map[string]structuralschema.Structural{ + "embedded": {Extensions: structuralschema.Extensions{XEmbeddedResource: true}}, + "nested": { + Extensions: structuralschema.Extensions{XEmbeddedResource: true}, + Properties: map[string]structuralschema.Structural{ + "embedded": {Extensions: structuralschema.Extensions{XEmbeddedResource: true}}, + }, + }, + }, + } + + var obj map[string]interface{} + if err := json.Unmarshal([]byte(tt.object), &obj); err != nil { + t.Fatal(err) + } + + errs := Validate(nil, obj, schema, tt.includeRoot) + seenErrs := make([]bool, len(errs)) + + for _, expectedError := range tt.errors { + found := false + for i, err := range errs { + if expectedError.matches(err) && !seenErrs[i] { + found = true + seenErrs[i] = true + break + } + } + + if !found { + t.Errorf("expected %v at %v, got %v", expectedError.errorType, expectedError.path.String(), errs) + } + } + + for i, seen := range seenErrs { + if !seen { + t.Errorf("unexpected error: %v", errs[i]) + } + } + }) + } +} + +type validationMatch struct { + path *field.Path + errorType field.ErrorType +} + +func required(path ...string) validationMatch { + return validationMatch{path: field.NewPath(path[0], path[1:]...), errorType: field.ErrorTypeRequired} +} +func invalid(path ...string) validationMatch { + return validationMatch{path: field.NewPath(path[0], path[1:]...), errorType: field.ErrorTypeInvalid} +} +func invalidIndex(index int, path ...string) validationMatch { + return validationMatch{path: field.NewPath(path[0], path[1:]...).Index(index), errorType: field.ErrorTypeInvalid} +} +func unsupported(path ...string) validationMatch { + return validationMatch{path: field.NewPath(path[0], path[1:]...), errorType: field.ErrorTypeNotSupported} +} +func immutable(path ...string) validationMatch { + return validationMatch{path: field.NewPath(path[0], path[1:]...), errorType: field.ErrorTypeInvalid} +} +func forbidden(path ...string) validationMatch { + return validationMatch{path: field.NewPath(path[0], path[1:]...), errorType: field.ErrorTypeForbidden} +} + +func (v validationMatch) matches(err *field.Error) bool { + return err.Type == v.errorType && err.Field == v.path.String() +}