From f2ee977afd72ee2a66fb491eb74713f1d14a12fd Mon Sep 17 00:00:00 2001 From: Jiahui Feng Date: Wed, 14 Dec 2022 09:18:27 -0800 Subject: [PATCH 1/8] refit CEL typing library to use OpenAPI schemas. --- .../apiserver/pkg/cel/openapi/extensions.go | 81 ++ .../apiserver/pkg/cel/openapi/maplist.go | 179 +++++ .../apiserver/pkg/cel/openapi/maplist_test.go | 309 ++++++++ .../apiserver/pkg/cel/openapi/schemas.go | 262 +++++++ .../apiserver/pkg/cel/openapi/schemas_test.go | 463 ++++++++++++ .../apiserver/pkg/cel/openapi/values.go | 702 ++++++++++++++++++ .../apiserver/pkg/cel/openapi/values_test.go | 660 ++++++++++++++++ 7 files changed, 2656 insertions(+) create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/extensions.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist_test.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas_test.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/values.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/values_test.go diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/extensions.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/extensions.go new file mode 100644 index 00000000000..e7817cfe230 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/extensions.go @@ -0,0 +1,81 @@ +/* +Copyright 2022 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 ( + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +var intOrStringFormat = intstr.IntOrString{}.OpenAPISchemaFormat() + +func isExtension(schema *spec.Schema, key string) bool { + v, ok := schema.Extensions.GetBool(key) + return v && ok +} + +func isXIntOrString(schema *spec.Schema) bool { + return schema.Format == intOrStringFormat || isExtension(schema, extIntOrString) +} + +func isXEmbeddedResource(schema *spec.Schema) bool { + return isExtension(schema, extEmbeddedResource) +} + +func isXPreserveUnknownFields(schema *spec.Schema) bool { + return isExtension(schema, extPreserveUnknownFields) +} + +func getXListType(schema *spec.Schema) string { + s, _ := schema.Extensions.GetString(extListType) + return s +} + +func getXListMapKeys(schema *spec.Schema) []string { + items, ok := schema.Extensions[extListMapKeys] + if !ok { + return nil + } + // items may be any of + // - a slice of string + // - a slice of interface{}, a.k.a any, but item's real type is string + // there is no direct conversion, so do that manually + switch items.(type) { + case []string: + return items.([]string) + case []any: + a := items.([]any) + result := make([]string, 0, len(a)) + for _, item := range a { + // item must be a string + s, ok := item.(string) + if !ok { + return nil + } + result = append(result, s) + } + return result + } + // no further attempt of handling unexpected type + return nil +} + +const extIntOrString = "x-kubernetes-int-or-string" +const extEmbeddedResource = "x-kubernetes-embedded-resource" +const extPreserveUnknownFields = "x-kubernetes-preserve-unknown-fields" +const extListType = "x-kubernetes-list-type" +const extListMapKeys = "x-kubernetes-list-map-keys" diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go new file mode 100644 index 00000000000..d018fc8a10d --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go @@ -0,0 +1,179 @@ +/* +Copyright 2022 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 ( + "fmt" + "strings" + + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// mapList provides a "lookup by key" operation for lists (arrays) with x-kubernetes-list-type=map. +type mapList interface { + // get returns the first element having given key, for all + // x-kubernetes-list-map-keys, to the provided object. If the provided object isn't itself a valid mapList element, + // get returns nil. + get(interface{}) interface{} +} + +type keyStrategy interface { + // CompositeKeyFor returns a composite key for the provided object, if possible, and a + // boolean that indicates whether or not a key could be generated for the provided object. + CompositeKeyFor(map[string]interface{}) (interface{}, bool) +} + +// singleKeyStrategy is a cheaper strategy for associative lists that have exactly one key. +type singleKeyStrategy struct { + key string +} + +// CompositeKeyFor directly returns the value of the single key to +// use as a composite key. +func (ks *singleKeyStrategy) CompositeKeyFor(obj map[string]interface{}) (interface{}, bool) { + v, ok := obj[ks.key] + if !ok { + return nil, false + } + + switch v.(type) { + case bool, float64, int64, string: + return v, true + default: + return nil, false // non-scalar + } +} + +// multiKeyStrategy computes a composite key of all key values. +type multiKeyStrategy struct { + sts *spec.Schema +} + +// CompositeKeyFor returns a composite key computed from the values of all +// keys. +func (ks *multiKeyStrategy) CompositeKeyFor(obj map[string]interface{}) (interface{}, bool) { + const keyDelimiter = "\x00" // 0 byte should never appear in the composite key except as delimiter + + var delimited strings.Builder + for _, key := range getXListMapKeys(ks.sts) { + v, ok := obj[key] + if !ok { + return nil, false + } + + switch v.(type) { + case bool: + fmt.Fprintf(&delimited, keyDelimiter+"%t", v) + case float64: + fmt.Fprintf(&delimited, keyDelimiter+"%f", v) + case int64: + fmt.Fprintf(&delimited, keyDelimiter+"%d", v) + case string: + fmt.Fprintf(&delimited, keyDelimiter+"%q", v) + default: + return nil, false // values must be scalars + } + } + return delimited.String(), true +} + +// emptyMapList is a mapList containing no elements. +type emptyMapList struct{} + +func (emptyMapList) get(interface{}) interface{} { + return nil +} + +type mapListImpl struct { + sts *spec.Schema + ks keyStrategy + // keyedItems contains all lazily keyed map items + keyedItems map[interface{}]interface{} + // unkeyedItems contains all map items that have not yet been keyed + unkeyedItems []interface{} +} + +func (a *mapListImpl) get(obj interface{}) interface{} { + mobj, ok := obj.(map[string]interface{}) + if !ok { + return nil + } + + key, ok := a.ks.CompositeKeyFor(mobj) + if !ok { + return nil + } + if match, ok := a.keyedItems[key]; ok { + return match + } + // keep keying items until we either find a match or run out of unkeyed items + for len(a.unkeyedItems) > 0 { + // dequeue an unkeyed item + item := a.unkeyedItems[0] + a.unkeyedItems = a.unkeyedItems[1:] + + // key the item + mitem, ok := item.(map[string]interface{}) + if !ok { + continue + } + itemKey, ok := a.ks.CompositeKeyFor(mitem) + if !ok { + continue + } + if _, exists := a.keyedItems[itemKey]; !exists { + a.keyedItems[itemKey] = mitem + } + + // if it matches, short-circuit + if itemKey == key { + return mitem + } + } + + return nil +} + +func makeKeyStrategy(sts *spec.Schema) keyStrategy { + listMapKeys := getXListMapKeys(sts) + if len(listMapKeys) == 1 { + key := listMapKeys[0] + return &singleKeyStrategy{ + key: key, + } + } + + return &multiKeyStrategy{ + sts: sts, + } +} + +// makeMapList returns a queryable interface over the provided x-kubernetes-list-type=map +// keyedItems. If the provided schema is _not_ an array with x-kubernetes-list-type=map, returns an +// empty mapList. +func makeMapList(sts *spec.Schema, items []interface{}) (rv mapList) { + if !sts.Type.Contains("array") || getXListType(sts) != "map" || len(getXListMapKeys(sts)) == 0 { + return emptyMapList{} + } + ks := makeKeyStrategy(sts) + return &mapListImpl{ + sts: sts, + ks: ks, + keyedItems: map[interface{}]interface{}{}, + unkeyedItems: items, + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist_test.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist_test.go new file mode 100644 index 00000000000..134a0f2cdbb --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist_test.go @@ -0,0 +1,309 @@ +/* +Copyright 2022 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 ( + "reflect" + "testing" + + "k8s.io/kube-openapi/pkg/validation/spec" +) + +func TestMapList(t *testing.T) { + for _, tc := range []struct { + name string + sts *spec.Schema + items []interface{} + warmUpQueries []interface{} + query interface{} + expected interface{} + }{ + { + name: "default list type", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }}, + query: map[string]interface{}{}, + expected: nil, + }, + { + name: "non list type", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"map"}, + }}, + query: map[string]interface{}{}, + expected: nil, + }, + { + name: "non-map list type", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }, + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + extListType: listTypeSet, + }}}, + query: map[string]interface{}{}, + expected: nil, + }, + { + name: "no keys", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }}, + query: map[string]interface{}{}, + expected: nil, + }, + { + name: "single key", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }, + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + extListType: listTypeMap, + extListMapKeys: []any{"k"}, + }}}, + items: []interface{}{ + map[string]interface{}{ + "k": "a", + "v1": "a", + }, + map[string]interface{}{ + "k": "b", + "v1": "b", + }, + }, + query: map[string]interface{}{ + "k": "b", + "v1": "B", + }, + expected: map[string]interface{}{ + "k": "b", + "v1": "b", + }, + }, + { + name: "single key ignoring non-map query", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }}, + items: []interface{}{ + map[string]interface{}{ + "k": "a", + "v1": "a", + }, + }, + query: 42, + expected: nil, + }, + { + name: "single key ignoring unkeyable query", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }, + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + extListType: listTypeMap, + extListMapKeys: []any{"k"}, + }}}, + items: []interface{}{ + map[string]interface{}{ + "k": "a", + "v1": "a", + }, + }, + query: map[string]interface{}{ + "k": map[string]interface{}{ + "keys": "must", + "be": "scalars", + }, + "v1": "A", + }, + expected: nil, + }, + { + name: "ignores item of invalid type", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }, + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + extListType: listTypeMap, + extListMapKeys: []any{"k"}, + }}}, + items: []interface{}{ + map[string]interface{}{ + "k": "a", + "v1": "a", + }, + 5, + }, + query: map[string]interface{}{ + "k": "a", + "v1": "A", + }, + expected: map[string]interface{}{ + "k": "a", + "v1": "a", + }, + }, + { + name: "keep first entry when duplicated keys are encountered", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }, + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + extListType: listTypeMap, + extListMapKeys: []any{"k"}, + }}}, + items: []interface{}{ + map[string]interface{}{ + "k": "a", + "v1": "a", + }, + map[string]interface{}{ + "k": "a", + "v1": "b", + }, + }, + query: map[string]interface{}{ + "k": "a", + "v1": "A", + }, + expected: map[string]interface{}{ + "k": "a", + "v1": "a", + }, + }, + { + name: "keep first entry when duplicated multi-keys are encountered", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }, + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + extListType: listTypeMap, + extListMapKeys: []any{"k1", "k2"}, + }}}, + items: []interface{}{ + map[string]interface{}{ + "k1": "a", + "k2": "b", + "v1": "a", + }, + map[string]interface{}{ + "k1": "a", + "k2": "b", + "v1": "b", + }, + map[string]interface{}{ + "k1": "x", + "k2": "y", + "v1": "z", + }, + }, + warmUpQueries: []interface{}{ + map[string]interface{}{ + "k1": "x", + "k2": "y", + }, + }, + query: map[string]interface{}{ + "k1": "a", + "k2": "b", + }, + expected: map[string]interface{}{ + "k1": "a", + "k2": "b", + "v1": "a", + }, + }, + { + name: "multiple keys with defaults ignores item with nil value for key", + sts: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Properties: map[string]spec.Schema{ + "kb": {SchemaProps: spec.SchemaProps{ + Default: true, + }}, + "kf": {SchemaProps: spec.SchemaProps{ + Default: 2.0, + }}, + "ki": {SchemaProps: spec.SchemaProps{ + Default: int64(64), + }}, + "ks": { + SchemaProps: spec.SchemaProps{ + Default: "hello", + }}, + }, + }, + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + extListType: listTypeMap, + extListMapKeys: []any{"kb", "kf", "ki", "ks"}, + }}}, + items: []interface{}{ + map[string]interface{}{ + "kb": nil, + "kf": float64(2.0), + "ki": int64(42), + "ks": "hello", + "v1": "a", + }, + map[string]interface{}{ + "kb": false, + "kf": float64(2.0), + "ki": int64(42), + "ks": "hello", + "v1": "b", + }, + }, + query: map[string]interface{}{ + "kb": false, + "kf": float64(2.0), + "ki": int64(42), + "ks": "hello", + "v1": "B", + }, + expected: map[string]interface{}{ + "kb": false, + "kf": float64(2.0), + "ki": int64(42), + "ks": "hello", + "v1": "b", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + mapList := makeMapList(tc.sts, tc.items) + for _, warmUp := range tc.warmUpQueries { + mapList.get(warmUp) + } + actual := mapList.get(tc.query) + if !reflect.DeepEqual(tc.expected, actual) { + t.Errorf("got: %v, expected %v", actual, tc.expected) + } + }) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas.go new file mode 100644 index 00000000000..28a3663af23 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas.go @@ -0,0 +1,262 @@ +/* +Copyright 2022 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 ( + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + + apiservercel "k8s.io/apiserver/pkg/cel" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +const maxRequestSizeBytes = apiservercel.DefaultMaxRequestSizeBytes + +// SchemaDeclType converts the structural schema to a CEL declaration, or returns nil if the +// structural schema should not be exposed in CEL expressions. +// Set isResourceRoot to true for the root of a custom resource or embedded resource. +// +// Schemas with XPreserveUnknownFields not exposed unless they are objects. Array and "maps" schemas +// are not exposed if their items or additionalProperties schemas are not exposed. Object Properties are not exposed +// if their schema is not exposed. +// +// The CEL declaration for objects with XPreserveUnknownFields does not expose unknown fields. +func SchemaDeclType(s *spec.Schema, isResourceRoot bool) *apiservercel.DeclType { + if s == nil { + return nil + } + if isXIntOrString(s) { + // schemas using XIntOrString are not required to have a type. + + // intOrStringType represents the x-kubernetes-int-or-string union type in CEL expressions. + // In CEL, the type is represented as dynamic value, which can be thought of as a union type of all types. + // All type checking for XIntOrString is deferred to runtime, so all access to values of this type must + // be guarded with a type check, e.g.: + // + // To require that the string representation be a percentage: + // `type(intOrStringField) == string && intOrStringField.matches(r'(\d+(\.\d+)?%)')` + // To validate requirements on both the int and string representation: + // `type(intOrStringField) == int ? intOrStringField < 5 : double(intOrStringField.replace('%', '')) < 0.5 + // + dyn := apiservercel.NewSimpleTypeWithMinSize("dyn", cel.DynType, nil, 1) // smallest value for a serialized x-kubernetes-int-or-string is 0 + // handle x-kubernetes-int-or-string by returning the max length/min serialized size of the largest possible string + dyn.MaxElements = maxRequestSizeBytes - 2 + return dyn + } + + // We ignore XPreserveUnknownFields since we don't support validation rules on + // data that we don't have schema information for. + + if isResourceRoot { + // 'apiVersion', 'kind', 'metadata.name' and 'metadata.generateName' are always accessible to validator rules + // at the root of resources, even if not specified in the schema. + // This includes the root of a custom resource and the root of XEmbeddedResource objects. + s = WithTypeAndObjectMeta(s) + } + + // If the schema is not an "int-or-string", type must present. + if len(s.Type) == 0 { + return nil + } + + switch s.Type[0] { + case "array": + if s.Items != nil { + itemsType := SchemaDeclType(s.Items.Schema, isXEmbeddedResource(s.Items.Schema)) + if itemsType == nil { + return nil + } + var maxItems int64 + if s.MaxItems != nil { + maxItems = zeroIfNegative(*s.MaxItems) + } else { + maxItems = estimateMaxArrayItemsFromMinSize(itemsType.MinSerializedSize) + } + return apiservercel.NewListType(itemsType, maxItems) + } + return nil + case "object": + if s.AdditionalProperties != nil && s.AdditionalProperties.Schema != nil { + propsType := SchemaDeclType(s.AdditionalProperties.Schema, isXEmbeddedResource(s.AdditionalProperties.Schema)) + if propsType != nil { + var maxProperties int64 + if s.MaxProperties != nil { + maxProperties = zeroIfNegative(*s.MaxProperties) + } else { + maxProperties = estimateMaxAdditionalPropertiesFromMinSize(propsType.MinSerializedSize) + } + return apiservercel.NewMapType(apiservercel.StringType, propsType, maxProperties) + } + return nil + } + fields := make(map[string]*apiservercel.DeclField, len(s.Properties)) + + required := map[string]bool{} + if s.Required != nil { + for _, f := range s.Required { + required[f] = true + } + } + // an object will always be serialized at least as {}, so account for that + minSerializedSize := int64(2) + for name, prop := range s.Properties { + var enumValues []interface{} + if prop.Enum != nil { + for _, e := range prop.Enum { + enumValues = append(enumValues, e) + } + } + if fieldType := SchemaDeclType(&prop, isXEmbeddedResource(&prop)); fieldType != nil { + if propName, ok := apiservercel.Escape(name); ok { + fields[propName] = apiservercel.NewDeclField(propName, fieldType, required[name], enumValues, prop.Default) + } + // the min serialized size for an object is 2 (for {}) plus the min size of all its required + // properties + // only include required properties without a default value; default values are filled in + // server-side + if required[name] && prop.Default == nil { + minSerializedSize += int64(len(name)) + fieldType.MinSerializedSize + 4 + } + } + } + objType := apiservercel.NewObjectType("object", fields) + objType.MinSerializedSize = minSerializedSize + return objType + case "string": + switch s.Format { + case "byte": + byteWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("bytes", cel.BytesType, types.Bytes([]byte{}), apiservercel.MinStringSize) + if s.MaxLength != nil { + byteWithMaxLength.MaxElements = zeroIfNegative(*s.MaxLength) + } else { + byteWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) + } + return byteWithMaxLength + case "duration": + durationWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("duration", cel.DurationType, types.Duration{Duration: time.Duration(0)}, int64(apiservercel.MinDurationSizeJSON)) + durationWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) + return durationWithMaxLength + case "date": + timestampWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("timestamp", cel.TimestampType, types.Timestamp{Time: time.Time{}}, int64(apiservercel.JSONDateSize)) + timestampWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) + return timestampWithMaxLength + case "date-time": + timestampWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("timestamp", cel.TimestampType, types.Timestamp{Time: time.Time{}}, int64(apiservercel.MinDatetimeSizeJSON)) + timestampWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) + return timestampWithMaxLength + } + + strWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("string", cel.StringType, types.String(""), apiservercel.MinStringSize) + if s.MaxLength != nil { + // multiply the user-provided max length by 4 in the case of an otherwise-untyped string + // we do this because the OpenAPIv3 spec indicates that maxLength is specified in runes/code points, + // but we need to reason about length for things like request size, so we use bytes in this code (and an individual + // unicode code point can be up to 4 bytes long) + strWithMaxLength.MaxElements = zeroIfNegative(*s.MaxLength) * 4 + } else { + strWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) + } + return strWithMaxLength + case "boolean": + return apiservercel.BoolType + case "number": + return apiservercel.DoubleType + case "integer": + return apiservercel.IntType + } + return nil +} + +func zeroIfNegative(v int64) int64 { + if v < 0 { + return 0 + } + return v +} + +// WithTypeAndObjectMeta ensures the kind, apiVersion and +// metadata.name and metadata.generateName properties are specified, making a shallow copy of the provided schema if needed. +func WithTypeAndObjectMeta(s *spec.Schema) *spec.Schema { + if s.Properties != nil && + s.Properties["kind"].Type.Contains("string") && + s.Properties["apiVersion"].Type.Contains("string") && + s.Properties["metadata"].Type.Contains("object") && + s.Properties["metadata"].Properties != nil && + s.Properties["metadata"].Properties["name"].Type.Contains("string") && + s.Properties["metadata"].Properties["generateName"].Type.Contains("string") { + return s + } + result := *s + props := make(map[string]spec.Schema, len(s.Properties)) + for k, prop := range s.Properties { + props[k] = prop + } + stringType := spec.StringProperty() + props["kind"] = *stringType + props["apiVersion"] = *stringType + props["metadata"] = spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": *stringType, + "generateName": *stringType, + }, + }, + } + result.Properties = props + + return &result +} + +// estimateMaxStringLengthPerRequest estimates the maximum string length (in characters) +// of a string compatible with the format requirements in the provided schema. +// must only be called on schemas of type "string" or x-kubernetes-int-or-string: true +func estimateMaxStringLengthPerRequest(s *spec.Schema) int64 { + if isXIntOrString(s) { + return maxRequestSizeBytes - 2 + } + switch s.Format { + case "duration": + return apiservercel.MaxDurationSizeJSON + case "date": + return apiservercel.JSONDateSize + case "date-time": + return apiservercel.MaxDatetimeSizeJSON + default: + // subtract 2 to account for "" + return maxRequestSizeBytes - 2 + } +} + +// estimateMaxArrayItemsPerRequest estimates the maximum number of array items with +// the provided minimum serialized size that can fit into a single request. +func estimateMaxArrayItemsFromMinSize(minSize int64) int64 { + // subtract 2 to account for [ and ] + return (maxRequestSizeBytes - 2) / (minSize + 1) +} + +// estimateMaxAdditionalPropertiesPerRequest estimates the maximum number of additional properties +// with the provided minimum serialized size that can fit into a single request. +func estimateMaxAdditionalPropertiesFromMinSize(minSize int64) int64 { + // 2 bytes for key + "" + colon + comma + smallest possible value, realistically the actual keys + // will all vary in length + keyValuePairSize := minSize + 6 + // subtract 2 to account for { and } + return (maxRequestSizeBytes - 2) / keyValuePairSize +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas_test.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas_test.go new file mode 100644 index 00000000000..c48aea73867 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas_test.go @@ -0,0 +1,463 @@ +/* +Copyright 2022 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 ( + "reflect" + "testing" + + "github.com/google/cel-go/common/types" + + "google.golang.org/protobuf/proto" + + apiservercel "k8s.io/apiserver/pkg/cel" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +func TestSchemaDeclType(t *testing.T) { + ts := testSchema() + cust := SchemaDeclType(ts, false) + if cust.TypeName() != "object" { + t.Errorf("incorrect type name, got %v, wanted object", cust.TypeName()) + } + if len(cust.Fields) != 4 { + t.Errorf("incorrect number of fields, got %d, wanted 4", len(cust.Fields)) + } + for _, f := range cust.Fields { + prop, found := ts.Properties[f.Name] + if !found { + t.Errorf("type field not found in schema, field: %s", f.Name) + } + fdv := f.DefaultValue() + if prop.Default != nil { + pdv := types.DefaultTypeAdapter.NativeToValue(prop.Default) + if !reflect.DeepEqual(fdv, pdv) { + t.Errorf("field and schema do not agree on default value for field: %s, field value: %v, schema default: %v", f.Name, fdv, pdv) + } + } + if (len(prop.Enum) == 0) && len(f.EnumValues()) != 0 { + t.Errorf("field had more enum values than the property. field: %s", f.Name) + } + + fevs := f.EnumValues() + for _, fev := range fevs { + found := false + for _, pev := range prop.Enum { + celpev := types.DefaultTypeAdapter.NativeToValue(pev) + if reflect.DeepEqual(fev, celpev) { + found = true + break + } + } + if !found { + t.Errorf( + "could not find field enum value in property definition. field: %s, enum: %v", + f.Name, fev) + } + } + + } + for _, name := range ts.Required { + df, found := cust.FindField(name) + if !found { + t.Errorf("custom type missing required field. field=%s", name) + } + if !df.Required { + t.Errorf("field marked as required in schema, but optional in type. field=%s", df.Name) + } + } + +} + +func TestSchemaDeclTypes(t *testing.T) { + ts := testSchema() + cust := SchemaDeclType(ts, true).MaybeAssignTypeName("CustomObject") + typeMap := apiservercel.FieldTypeMap("CustomObject", cust) + nested, _ := cust.FindField("nested") + metadata, _ := cust.FindField("metadata") + expectedObjTypeMap := map[string]*apiservercel.DeclType{ + "CustomObject": cust, + "CustomObject.nested": nested.Type, + "CustomObject.metadata": metadata.Type, + } + objTypeMap := map[string]*apiservercel.DeclType{} + for name, t := range typeMap { + if t.IsObject() { + objTypeMap[name] = t + } + } + if len(objTypeMap) != len(expectedObjTypeMap) { + t.Errorf("got different type set. got=%v, wanted=%v", objTypeMap, expectedObjTypeMap) + } + for exp, expType := range expectedObjTypeMap { + actType, found := objTypeMap[exp] + if !found { + t.Errorf("missing type in rule types: %s", exp) + continue + } + expT, err := expType.ExprType() + if err != nil { + t.Errorf("fail to get cel type: %s", err) + } + actT, err := actType.ExprType() + if err != nil { + t.Errorf("fail to get cel type: %s", err) + } + if !proto.Equal(expT, actT) { + t.Errorf("incompatible CEL types. got=%v, wanted=%v", expT, actT) + } + } +} + +func testSchema() *spec.Schema { + // Manual construction of a schema with the following definition: + // + // schema: + // type: object + // metadata: + // custom_type: "CustomObject" + // required: + // - name + // - value + // properties: + // name: + // type: string + // nested: + // type: object + // properties: + // subname: + // type: string + // flags: + // type: object + // additionalProperties: + // type: boolean + // dates: + // type: array + // items: + // type: string + // format: date-time + // metadata: + // type: object + // additionalProperties: + // type: object + // properties: + // key: + // type: string + // values: + // type: array + // items: string + // value: + // type: integer + // format: int64 + // default: 1 + // enum: [1,2,3] + ts := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": *spec.StringProperty(), + "value": {SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + Default: int64(1), + Format: "int64", + Enum: []any{1, 2, 3}, + }}, + "nested": {SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "subname": *spec.StringProperty(), + "flags": {SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Schema: spec.BooleanProperty(), + }, + }}, + "dates": {SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "date-time", + }}}}}, + }, + }, + }, + "metadata": {SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": *spec.StringProperty(), + "value": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }}}, + }, + }, + }, + }}, + }}} + return ts +} + +func arraySchema(arrayType, format string, maxItems *int64) *spec.Schema { + return &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{arrayType}, + Format: format, + }}}, + MaxItems: maxItems, + }, + } +} + +func maxPtr(max int64) *int64 { + return &max +} + +func TestEstimateMaxLengthJSON(t *testing.T) { + type maxLengthTest struct { + Name string + InputSchema *spec.Schema + ExpectedMaxElements int64 + } + tests := []maxLengthTest{ + { + Name: "booleanArray", + InputSchema: arraySchema("boolean", "", nil), + // expected JSON is [true,true,...], so our length should be (maxRequestSizeBytes - 2) / 5 + ExpectedMaxElements: 629145, + }, + { + Name: "durationArray", + InputSchema: arraySchema("string", "duration", nil), + // expected JSON is ["0","0",...] so our length should be (maxRequestSizeBytes - 2) / 4 + ExpectedMaxElements: 786431, + }, + { + Name: "datetimeArray", + InputSchema: arraySchema("string", "date-time", nil), + // expected JSON is ["2000-01-01T01:01:01","2000-01-01T01:01:01",...] so our length should be (maxRequestSizeBytes - 2) / 22 + ExpectedMaxElements: 142987, + }, + { + Name: "dateArray", + InputSchema: arraySchema("string", "date", nil), + // expected JSON is ["2000-01-01","2000-01-02",...] so our length should be (maxRequestSizeBytes - 2) / 13 + ExpectedMaxElements: 241978, + }, + { + Name: "numberArray", + InputSchema: arraySchema("integer", "", nil), + // expected JSON is [0,0,...] so our length should be (maxRequestSizeBytes - 2) / 2 + ExpectedMaxElements: 1572863, + }, + { + Name: "stringArray", + InputSchema: arraySchema("string", "", nil), + // expected JSON is ["","",...] so our length should be (maxRequestSizeBytes - 2) / 3 + ExpectedMaxElements: 1048575, + }, + { + Name: "stringMap", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }}, + }, + }}, + // expected JSON is {"":"","":"",...} so our length should be (3000000 - 2) / 6 + ExpectedMaxElements: 393215, + }, + { + Name: "objectOptionalPropertyArray", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "required": *spec.StringProperty(), + "optional": *spec.StringProperty(), + }, + Required: []string{"required"}, + }}}, + }}, + // expected JSON is [{"required":"",},{"required":"",},...] so our length should be (maxRequestSizeBytes - 2) / 17 + ExpectedMaxElements: 185042, + }, + { + Name: "arrayWithLength", + InputSchema: arraySchema("integer", "int64", maxPtr(10)), + // manually set by MaxItems + ExpectedMaxElements: 10, + }, + { + Name: "stringWithLength", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + MaxLength: maxPtr(20), + }}, + // manually set by MaxLength, but we expect a 4x multiplier compared to the original input + // since OpenAPIv3 maxLength uses code points, but DeclType works with bytes + ExpectedMaxElements: 80, + }, + { + Name: "mapWithLength", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Schema: spec.StringProperty(), + }, + Format: "string", + MaxProperties: maxPtr(15), + }}, + // manually set by MaxProperties + ExpectedMaxElements: 15, + }, + { + Name: "durationMaxSize", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "duration", + }}, + // should be exactly equal to maxDurationSizeJSON + ExpectedMaxElements: apiservercel.MaxDurationSizeJSON, + }, + { + Name: "dateSize", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "date", + }}, + // should be exactly equal to dateSizeJSON + ExpectedMaxElements: apiservercel.JSONDateSize, + }, + { + Name: "maxdatetimeSize", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "date-time", + }}, + // should be exactly equal to maxDatetimeSizeJSON + ExpectedMaxElements: apiservercel.MaxDatetimeSizeJSON, + }, + { + Name: "maxintOrStringSize", + InputSchema: &spec.Schema{ + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + extIntOrString: true, + }}}, + // should be exactly equal to maxRequestSizeBytes - 2 (to allow for quotes in the case of a string) + ExpectedMaxElements: apiservercel.DefaultMaxRequestSizeBytes - 2, + }, + { + Name: "objectDefaultFieldArray", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "field": {SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Default: "default", + }, + }}, + Required: []string{"field"}, + }}}, + }, + }, + // expected JSON is [{},{},...] so our length should be (maxRequestSizeBytes - 2) / 3 + ExpectedMaxElements: 1048575, + }, + { + Name: "byteStringSize", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "byte", + }}, + // expected JSON is "" so our length should be (maxRequestSizeBytes - 2) + ExpectedMaxElements: 3145726, + }, + { + Name: "byteStringSetMaxLength", + InputSchema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "byte", + MaxLength: maxPtr(20), + }}, + // note that unlike regular strings we don't have to take unicode into account, + // so we expect the max length to be exactly equal to the user-supplied one + ExpectedMaxElements: 20, + }, + } + for _, testCase := range tests { + t.Run(testCase.Name, func(t *testing.T) { + decl := SchemaDeclType(testCase.InputSchema, false) + if decl.MaxElements != testCase.ExpectedMaxElements { + t.Errorf("wrong maxElements (got %d, expected %d)", decl.MaxElements, testCase.ExpectedMaxElements) + } + }) + } +} + +func genNestedSchema(depth int) *spec.Schema { + var generator func(d int) spec.Schema + generator = func(d int) spec.Schema { + nodeTemplate := &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{}, + }} + if d == 1 { + return *nodeTemplate + } else { + mapType := generator(d - 1) + nodeTemplate.AdditionalProperties.Schema = &mapType + return *nodeTemplate + } + } + schema := generator(depth) + return &schema +} + +func BenchmarkDeeplyNestedSchemaDeclType(b *testing.B) { + benchmarkSchema := genNestedSchema(10) + b.ResetTimer() + for i := 0; i < b.N; i++ { + SchemaDeclType(benchmarkSchema, false) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/values.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/values.go new file mode 100644 index 00000000000..b435f98c64e --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/values.go @@ -0,0 +1,702 @@ +/* +Copyright 2021 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 ( + "fmt" + "reflect" + "sync" + "time" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apiserver/pkg/cel" + "k8s.io/kube-openapi/pkg/validation/spec" + "k8s.io/kube-openapi/pkg/validation/strfmt" +) + +// UnstructuredToVal converts a Kubernetes unstructured data element to a CEL Val. +// The root schema of custom resource schema is expected contain type meta and object meta schemas. +// If Embedded resources do not contain type meta and object meta schemas, they will be added automatically. +func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { + if unstructured == nil { + if schema.Nullable { + return types.NullValue + } + return types.NewErr("invalid data, got null for schema with nullable=false") + } + if isXIntOrString(schema) { + switch v := unstructured.(type) { + case string: + return types.String(v) + case int: + return types.Int(v) + case int32: + return types.Int(v) + case int64: + return types.Int(v) + } + return types.NewErr("invalid data, expected XIntOrString value to be either a string or integer") + } + if schema.Type.Contains("object") { + m, ok := unstructured.(map[string]interface{}) + if !ok { + return types.NewErr("invalid data, expected a map for the provided schema with type=object") + } + if isXEmbeddedResource(schema) || schema.Properties != nil { + if isXEmbeddedResource(schema) { + schema = WithTypeAndObjectMeta(schema) + } + return &unstructuredMap{ + value: m, + schema: schema, + propSchema: func(key string) (*spec.Schema, bool) { + if schema, ok := schema.Properties[key]; ok { + return &schema, true + } + return nil, false + }, + } + } + if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { + return &unstructuredMap{ + value: m, + schema: schema, + propSchema: func(key string) (*spec.Schema, bool) { + return schema.AdditionalProperties.Schema, true + }, + } + } + // A object with x-kubernetes-preserve-unknown-fields but no properties or additionalProperties is treated + // as an empty object. + if isXPreserveUnknownFields(schema) { + return &unstructuredMap{ + value: m, + schema: schema, + propSchema: func(key string) (*spec.Schema, bool) { + return nil, false + }, + } + } + return types.NewErr("invalid object type, expected either Properties or AdditionalProperties with Allows=true and non-empty Schema") + } + + if schema.Type.Contains("array") { + l, ok := unstructured.([]interface{}) + if !ok { + return types.NewErr("invalid data, expected an array for the provided schema with type=array") + } + if schema.Items == nil { + return types.NewErr("invalid array type, expected Items with a non-empty Schema") + } + typedList := unstructuredList{elements: l, itemsSchema: schema.Items.Schema} + listType := getXListType(schema) + if listType != "" { + switch listType { + case "map": + mapKeys := getXListMapKeys(schema) + return &unstructuredMapList{unstructuredList: typedList, escapedKeyProps: escapeKeyProps(mapKeys)} + case "set": + return &unstructuredSetList{unstructuredList: typedList} + case "atomic": + return &typedList + default: + return types.NewErr("invalid x-kubernetes-list-type, expected 'map', 'set' or 'atomic' but got %s", listType) + } + } + return &typedList + } + + if schema.Type.Contains("string") { + str, ok := unstructured.(string) + if !ok { + return types.NewErr("invalid data, expected string, got %T", unstructured) + } + switch schema.Format { + case "duration": + d, err := strfmt.ParseDuration(str) + if err != nil { + return types.NewErr("Invalid duration %s: %v", str, err) + } + return types.Duration{Duration: d} + case "date": + d, err := time.Parse(strfmt.RFC3339FullDate, str) // strfmt uses this format for OpenAPIv3 value validation + if err != nil { + return types.NewErr("Invalid date formatted string %s: %v", str, err) + } + return types.Timestamp{Time: d} + case "date-time": + d, err := strfmt.ParseDateTime(str) + if err != nil { + return types.NewErr("Invalid date-time formatted string %s: %v", str, err) + } + return types.Timestamp{Time: time.Time(d)} + case "byte": + base64 := strfmt.Base64{} + err := base64.UnmarshalText([]byte(str)) + if err != nil { + return types.NewErr("Invalid byte formatted string %s: %v", str, err) + } + return types.Bytes(base64) + } + + return types.String(str) + } + if schema.Type.Contains("number") { + switch v := unstructured.(type) { + // float representations of whole numbers (e.g. 1.0, 0.0) can convert to int representations (e.g. 1, 0) in yaml + // to json translation, and then get parsed as int64s + case int: + return types.Double(v) + case int32: + return types.Double(v) + case int64: + return types.Double(v) + + case float32: + return types.Double(v) + case float64: + return types.Double(v) + default: + return types.NewErr("invalid data, expected float, got %T", unstructured) + } + } + if schema.Type.Contains("integer") { + switch v := unstructured.(type) { + case int: + return types.Int(v) + case int32: + return types.Int(v) + case int64: + return types.Int(v) + default: + return types.NewErr("invalid data, expected int, got %T", unstructured) + } + } + if schema.Type.Contains("boolean") { + b, ok := unstructured.(bool) + if !ok { + return types.NewErr("invalid data, expected bool, got %T", unstructured) + } + return types.Bool(b) + } + + if isXPreserveUnknownFields(schema) { + return &unknownPreserved{u: unstructured} + } + + return types.NewErr("invalid type, expected object, array, number, integer, boolean or string, or no type with x-kubernetes-int-or-string or x-kubernetes-preserve-unknown-fields is true, got %s", schema.Type) +} + +// unknownPreserved represents unknown data preserved in custom resources via x-kubernetes-preserve-unknown-fields. +// It preserves the data at runtime without assuming it is of any particular type and supports only equality checking. +// unknownPreserved should be used only for values are not directly accessible in CEL expressions, i.e. for data +// where there is no corresponding CEL type declaration. +type unknownPreserved struct { + u interface{} +} + +func (t *unknownPreserved) ConvertToNative(refType reflect.Type) (interface{}, error) { + return nil, fmt.Errorf("type conversion to '%s' not supported for values preserved by x-kubernetes-preserve-unknown-fields", refType) +} + +func (t *unknownPreserved) ConvertToType(typeValue ref.Type) ref.Val { + return types.NewErr("type conversion to '%s' not supported for values preserved by x-kubernetes-preserve-unknown-fields", typeValue.TypeName()) +} + +func (t *unknownPreserved) Equal(other ref.Val) ref.Val { + return types.Bool(equality.Semantic.DeepEqual(t.u, other.Value())) +} + +func (t *unknownPreserved) Type() ref.Type { + return types.UnknownType +} + +func (t *unknownPreserved) Value() interface{} { + return t.u // used by Equal checks +} + +// unstructuredMapList represents an unstructured data instance of an OpenAPI array with x-kubernetes-list-type=map. +type unstructuredMapList struct { + unstructuredList + escapedKeyProps []string + + sync.Once // for for lazy load of mapOfList since it is only needed if Equals is called + mapOfList map[interface{}]interface{} +} + +func (t *unstructuredMapList) getMap() map[interface{}]interface{} { + t.Do(func() { + t.mapOfList = make(map[interface{}]interface{}, len(t.elements)) + for _, e := range t.elements { + t.mapOfList[t.toMapKey(e)] = e + } + }) + return t.mapOfList +} + +// toMapKey returns a valid golang map key for the given element of the map list. +// element must be a valid map list entry where all map key props are scalar types (which are comparable in go +// and valid for use in a golang map key). +func (t *unstructuredMapList) toMapKey(element interface{}) interface{} { + eObj, ok := element.(map[string]interface{}) + if !ok { + return types.NewErr("unexpected data format for element of array with x-kubernetes-list-type=map: %T", element) + } + // Arrays are comparable in go and may be used as map keys, but maps and slices are not. + // So we can special case small numbers of key props as arrays and fall back to serialization + // for larger numbers of key props + if len(t.escapedKeyProps) == 1 { + return eObj[t.escapedKeyProps[0]] + } + if len(t.escapedKeyProps) == 2 { + return [2]interface{}{eObj[t.escapedKeyProps[0]], eObj[t.escapedKeyProps[1]]} + } + if len(t.escapedKeyProps) == 3 { + return [3]interface{}{eObj[t.escapedKeyProps[0]], eObj[t.escapedKeyProps[1]], eObj[t.escapedKeyProps[2]]} + } + + key := make([]interface{}, len(t.escapedKeyProps)) + for i, kf := range t.escapedKeyProps { + key[i] = eObj[kf] + } + return fmt.Sprintf("%v", key) +} + +// Equal on a map list ignores list element order. +func (t *unstructuredMapList) Equal(other ref.Val) ref.Val { + oMapList, ok := other.(traits.Lister) + if !ok { + return types.MaybeNoSuchOverloadErr(other) + } + sz := types.Int(len(t.elements)) + if sz != oMapList.Size() { + return types.False + } + tMap := t.getMap() + for it := oMapList.Iterator(); it.HasNext() == types.True; { + v := it.Next() + k := t.toMapKey(v.Value()) + tVal, ok := tMap[k] + if !ok { + return types.False + } + eq := UnstructuredToVal(tVal, t.itemsSchema).Equal(v) + if eq != types.True { + return eq // either false or error + } + } + return types.True +} + +// Add for a map list `X + Y` performs a merge where the array positions of all keys in `X` are preserved but the values +// are overwritten by values in `Y` when the key sets of `X` and `Y` intersect. Elements in `Y` with +// non-intersecting keys are appended, retaining their partial order. +func (t *unstructuredMapList) Add(other ref.Val) ref.Val { + oMapList, ok := other.(traits.Lister) + if !ok { + return types.MaybeNoSuchOverloadErr(other) + } + elements := make([]interface{}, len(t.elements)) + keyToIdx := map[interface{}]int{} + for i, e := range t.elements { + k := t.toMapKey(e) + keyToIdx[k] = i + elements[i] = e + } + for it := oMapList.Iterator(); it.HasNext() == types.True; { + v := it.Next().Value() + k := t.toMapKey(v) + if overwritePosition, ok := keyToIdx[k]; ok { + elements[overwritePosition] = v + } else { + elements = append(elements, v) + } + } + return &unstructuredMapList{ + unstructuredList: unstructuredList{elements: elements, itemsSchema: t.itemsSchema}, + escapedKeyProps: t.escapedKeyProps, + } +} + +// escapeKeyProps returns identifiers with Escape applied to each. +// Identifiers that cannot be escaped are left as-is. They are inaccessible to CEL programs but are +// are still needed internally to perform equality checks. +func escapeKeyProps(idents []string) []string { + result := make([]string, len(idents)) + for i, prop := range idents { + if escaped, ok := cel.Escape(prop); ok { + result[i] = escaped + } else { + result[i] = prop + } + } + return result +} + +// unstructuredSetList represents an unstructured data instance of an OpenAPI array with x-kubernetes-list-type=set. +type unstructuredSetList struct { + unstructuredList + escapedKeyProps []string + + sync.Once // for for lazy load of setOfList since it is only needed if Equals is called + set map[interface{}]struct{} +} + +func (t *unstructuredSetList) getSet() map[interface{}]struct{} { + // sets are only allowed to contain scalar elements, which are comparable in go, and can safely be used as + // golang map keys + t.Do(func() { + t.set = make(map[interface{}]struct{}, len(t.elements)) + for _, e := range t.elements { + t.set[e] = struct{}{} + } + }) + return t.set +} + +// Equal on a map list ignores list element order. +func (t *unstructuredSetList) Equal(other ref.Val) ref.Val { + oSetList, ok := other.(traits.Lister) + if !ok { + return types.MaybeNoSuchOverloadErr(other) + } + sz := types.Int(len(t.elements)) + if sz != oSetList.Size() { + return types.False + } + tSet := t.getSet() + for it := oSetList.Iterator(); it.HasNext() == types.True; { + next := it.Next().Value() + _, ok := tSet[next] + if !ok { + return types.False + } + } + return types.True +} + +// Add for a set list `X + Y` performs a union where the array positions of all elements in `X` are preserved and +// non-intersecting elements in `Y` are appended, retaining their partial order. +func (t *unstructuredSetList) Add(other ref.Val) ref.Val { + oSetList, ok := other.(traits.Lister) + if !ok { + return types.MaybeNoSuchOverloadErr(other) + } + elements := t.elements + set := t.getSet() + for it := oSetList.Iterator(); it.HasNext() == types.True; { + next := it.Next().Value() + if _, ok := set[next]; !ok { + set[next] = struct{}{} + elements = append(elements, next) + } + } + return &unstructuredSetList{ + unstructuredList: unstructuredList{elements: elements, itemsSchema: t.itemsSchema}, + escapedKeyProps: t.escapedKeyProps, + } +} + +// unstructuredList represents an unstructured data instance of an OpenAPI array with x-kubernetes-list-type=atomic (the default). +type unstructuredList struct { + elements []interface{} + itemsSchema *spec.Schema +} + +var _ = traits.Lister(&unstructuredList{}) + +func (t *unstructuredList) ConvertToNative(typeDesc reflect.Type) (interface{}, error) { + switch typeDesc.Kind() { + case reflect.Slice: + return t.elements, nil + } + return nil, fmt.Errorf("type conversion error from '%s' to '%s'", t.Type(), typeDesc) +} + +func (t *unstructuredList) ConvertToType(typeValue ref.Type) ref.Val { + switch typeValue { + case types.ListType: + return t + case types.TypeType: + return types.ListType + } + return types.NewErr("type conversion error from '%s' to '%s'", t.Type(), typeValue.TypeName()) +} + +func (t *unstructuredList) Equal(other ref.Val) ref.Val { + oList, ok := other.(traits.Lister) + if !ok { + return types.MaybeNoSuchOverloadErr(other) + } + sz := types.Int(len(t.elements)) + if sz != oList.Size() { + return types.False + } + for i := types.Int(0); i < sz; i++ { + eq := t.Get(i).Equal(oList.Get(i)) + if eq != types.True { + return eq // either false or error + } + } + return types.True +} + +func (t *unstructuredList) Type() ref.Type { + return types.ListType +} + +func (t *unstructuredList) Value() interface{} { + return t.elements +} + +func (t *unstructuredList) Add(other ref.Val) ref.Val { + oList, ok := other.(traits.Lister) + if !ok { + return types.MaybeNoSuchOverloadErr(other) + } + elements := t.elements + for it := oList.Iterator(); it.HasNext() == types.True; { + next := it.Next().Value() + elements = append(elements, next) + } + + return &unstructuredList{elements: elements, itemsSchema: t.itemsSchema} +} + +func (t *unstructuredList) Contains(val ref.Val) ref.Val { + if types.IsUnknownOrError(val) { + return val + } + var err ref.Val + sz := len(t.elements) + for i := 0; i < sz; i++ { + elem := UnstructuredToVal(t.elements[i], t.itemsSchema) + cmp := elem.Equal(val) + b, ok := cmp.(types.Bool) + if !ok && err == nil { + err = types.MaybeNoSuchOverloadErr(cmp) + } + if b == types.True { + return types.True + } + } + if err != nil { + return err + } + return types.False +} + +func (t *unstructuredList) Get(idx ref.Val) ref.Val { + iv, isInt := idx.(types.Int) + if !isInt { + return types.ValOrErr(idx, "unsupported index: %v", idx) + } + i := int(iv) + if i < 0 || i >= len(t.elements) { + return types.NewErr("index out of bounds: %v", idx) + } + return UnstructuredToVal(t.elements[i], t.itemsSchema) +} + +func (t *unstructuredList) Iterator() traits.Iterator { + items := make([]ref.Val, len(t.elements)) + for i, item := range t.elements { + itemCopy := item + items[i] = UnstructuredToVal(itemCopy, t.itemsSchema) + } + return &listIterator{unstructuredList: t, items: items} +} + +type listIterator struct { + *unstructuredList + items []ref.Val + idx int +} + +func (it *listIterator) HasNext() ref.Val { + return types.Bool(it.idx < len(it.items)) +} + +func (it *listIterator) Next() ref.Val { + item := it.items[it.idx] + it.idx++ + return item +} + +func (t *unstructuredList) Size() ref.Val { + return types.Int(len(t.elements)) +} + +// unstructuredMap represented an unstructured data instance of an OpenAPI object. +type unstructuredMap struct { + value map[string]interface{} + schema *spec.Schema + // propSchema finds the schema to use for a particular map key. + propSchema func(key string) (*spec.Schema, bool) +} + +var _ = traits.Mapper(&unstructuredMap{}) + +func (t *unstructuredMap) ConvertToNative(typeDesc reflect.Type) (interface{}, error) { + switch typeDesc.Kind() { + case reflect.Map: + return t.value, nil + } + return nil, fmt.Errorf("type conversion error from '%s' to '%s'", t.Type(), typeDesc) +} + +func (t *unstructuredMap) ConvertToType(typeValue ref.Type) ref.Val { + switch typeValue { + case types.MapType: + return t + case types.TypeType: + return types.MapType + } + return types.NewErr("type conversion error from '%s' to '%s'", t.Type(), typeValue.TypeName()) +} + +func (t *unstructuredMap) Equal(other ref.Val) ref.Val { + oMap, isMap := other.(traits.Mapper) + if !isMap { + return types.MaybeNoSuchOverloadErr(other) + } + if t.Size() != oMap.Size() { + return types.False + } + for key, value := range t.value { + if propSchema, ok := t.propSchema(key); ok { + ov, found := oMap.Find(types.String(key)) + if !found { + return types.False + } + v := UnstructuredToVal(value, propSchema) + vEq := v.Equal(ov) + if vEq != types.True { + return vEq // either false or error + } + } else { + // Must be an object with properties. + // Since we've encountered an unknown field, fallback to unstructured equality checking. + ouMap, ok := other.(*unstructuredMap) + if !ok { + // The compiler ensures equality is against the same type of object, so this should be unreachable + return types.MaybeNoSuchOverloadErr(other) + } + if oValue, ok := ouMap.value[key]; ok { + if !equality.Semantic.DeepEqual(value, oValue) { + return types.False + } + } + } + } + return types.True +} + +func (t *unstructuredMap) Type() ref.Type { + return types.MapType +} + +func (t *unstructuredMap) Value() interface{} { + return t.value +} + +func (t *unstructuredMap) Contains(key ref.Val) ref.Val { + v, found := t.Find(key) + if v != nil && types.IsUnknownOrError(v) { + return v + } + + return types.Bool(found) +} + +func (t *unstructuredMap) Get(key ref.Val) ref.Val { + v, found := t.Find(key) + if found { + return v + } + return types.ValOrErr(key, "no such key: %v", key) +} + +func (t *unstructuredMap) Iterator() traits.Iterator { + isObject := t.schema.Properties != nil + keys := make([]ref.Val, len(t.value)) + i := 0 + for k := range t.value { + if _, ok := t.propSchema(k); ok { + mapKey := k + if isObject { + if escaped, ok := cel.Escape(k); ok { + mapKey = escaped + } + } + keys[i] = types.String(mapKey) + i++ + } + } + return &mapIterator{unstructuredMap: t, keys: keys} +} + +type mapIterator struct { + *unstructuredMap + keys []ref.Val + idx int +} + +func (it *mapIterator) HasNext() ref.Val { + return types.Bool(it.idx < len(it.keys)) +} + +func (it *mapIterator) Next() ref.Val { + key := it.keys[it.idx] + it.idx++ + return key +} + +func (t *unstructuredMap) Size() ref.Val { + return types.Int(len(t.value)) +} + +func (t *unstructuredMap) Find(key ref.Val) (ref.Val, bool) { + isObject := t.schema.Properties != nil + keyStr, ok := key.(types.String) + if !ok { + return types.MaybeNoSuchOverloadErr(key), true + } + k := keyStr.Value().(string) + if isObject { + k, ok = cel.Unescape(k) + if !ok { + return nil, false + } + } + if v, ok := t.value[k]; ok { + // If this is an object with properties, not an object with additionalProperties, + // then null valued nullable fields are treated the same as absent optional fields. + if isObject && v == nil { + return nil, false + } + if propSchema, ok := t.propSchema(k); ok { + return UnstructuredToVal(v, propSchema), true + } + } + + return nil, false +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/values_test.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/values_test.go new file mode 100644 index 00000000000..0607327f8bb --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/values_test.go @@ -0,0 +1,660 @@ +/* +Copyright 2021 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 ( + "reflect" + "testing" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" + + "k8s.io/kube-openapi/pkg/validation/spec" +) + +var ( + listTypeSet = "set" + listTypeMap = "map" + stringSchema = spec.StringProperty() + intSchema = spec.Int64Property() + + mapListElementSchema = &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key": *stringSchema, + "val": *intSchema, + }, + }} + mapListSchema = &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{Schema: mapListElementSchema}, + }, + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]interface{}{ + extListType: listTypeMap, + extListMapKeys: []any{"key"}, + }}, + } + multiKeyMapListSchema = &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key1": *stringSchema, + "key2": *stringSchema, + "val": *intSchema, + }, + }}}}, + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]interface{}{ + extListType: listTypeMap, + extListMapKeys: []any{"key1", "key2"}, + }}, + } + setListSchema = &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{Schema: stringSchema}}, + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]interface{}{ + extListType: listTypeSet, + }}, + } + atomicListSchema = &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{Schema: stringSchema}, + }} + objectSchema = &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "field1": *stringSchema, + "field2": *stringSchema, + }, + }} + mapSchema = &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{Schema: stringSchema}, + }} +) + +func TestEquality(t *testing.T) { + cases := []struct { + name string + lhs ref.Val + rhs ref.Val + equal bool + }{ + { + name: "map lists are equal regardless of order", + lhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key": "a", + "val": 1, + }, + map[string]interface{}{ + "key": "b", + "val": 2, + }, + }, mapListSchema), + rhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key": "b", + "val": 2, + }, + map[string]interface{}{ + "key": "a", + "val": 1, + }, + }, mapListSchema), + equal: true, + }, + { + name: "map lists are not equal if contents differs", + lhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key": "a", + "val": 1, + }, + map[string]interface{}{ + "key": "b", + "val": 2, + }, + }, mapListSchema), + rhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key": "a", + "val": 1, + }, + map[string]interface{}{ + "key": "b", + "val": 3, + }, + }, mapListSchema), + equal: false, + }, + { + name: "map lists are not equal if length differs", + lhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key": "a", + "val": 1, + }, + map[string]interface{}{ + "key": "b", + "val": 2, + }, + }, mapListSchema), + rhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key": "a", + "val": 1, + }, + map[string]interface{}{ + "key": "b", + "val": 2, + }, + map[string]interface{}{ + "key": "c", + "val": 3, + }, + }, mapListSchema), + equal: false, + }, + { + name: "multi-key map lists are equal regardless of order", + lhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key1": "a1", + "key2": "a2", + "val": 1, + }, + map[string]interface{}{ + "key1": "b1", + "key2": "b2", + "val": 2, + }, + }, multiKeyMapListSchema), + rhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key1": "b1", + "key2": "b2", + "val": 2, + }, + map[string]interface{}{ + "key1": "a1", + "key2": "a2", + "val": 1, + }, + }, multiKeyMapListSchema), + equal: true, + }, + { + name: "multi-key map lists with different contents are not equal", + lhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key1": "a1", + "key2": "a2", + "val": 1, + }, + map[string]interface{}{ + "key1": "b1", + "key2": "b2", + "val": 2, + }, + }, multiKeyMapListSchema), + rhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key1": "a1", + "key2": "a2", + "val": 1, + }, + map[string]interface{}{ + "key1": "b1", + "key2": "b2", + "val": 3, + }, + }, multiKeyMapListSchema), + equal: false, + }, + { + name: "multi-key map lists with different keys are not equal", + lhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key1": "a1", + "key2": "a2", + "val": 1, + }, + map[string]interface{}{ + "key1": "b1", + "key2": "b2", + "val": 2, + }, + }, multiKeyMapListSchema), + rhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key1": "a1", + "key2": "a2", + "val": 1, + }, + map[string]interface{}{ + "key1": "c1", + "key2": "c2", + "val": 3, + }, + }, multiKeyMapListSchema), + equal: false, + }, + { + name: "multi-key map lists with different lengths are not equal", + lhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key1": "a1", + "key2": "a2", + "val": 1, + }, + }, multiKeyMapListSchema), + rhs: UnstructuredToVal([]interface{}{ + map[string]interface{}{ + "key1": "a1", + "key2": "a2", + "val": 1, + }, + map[string]interface{}{ + "key1": "b1", + "key2": "b2", + "val": 3, + }, + }, multiKeyMapListSchema), + equal: false, + }, + { + name: "set lists are equal regardless of order", + lhs: UnstructuredToVal([]interface{}{"a", "b"}, setListSchema), + rhs: UnstructuredToVal([]interface{}{"b", "a"}, setListSchema), + equal: true, + }, + { + name: "set lists are not equal if contents differ", + lhs: UnstructuredToVal([]interface{}{"a", "b"}, setListSchema), + rhs: UnstructuredToVal([]interface{}{"a", "c"}, setListSchema), + equal: false, + }, + { + name: "set lists are not equal if lengths differ", + lhs: UnstructuredToVal([]interface{}{"a", "b"}, setListSchema), + rhs: UnstructuredToVal([]interface{}{"a", "b", "c"}, setListSchema), + equal: false, + }, + { + name: "identical atomic lists are equal", + lhs: UnstructuredToVal([]interface{}{"a", "b"}, atomicListSchema), + rhs: UnstructuredToVal([]interface{}{"a", "b"}, atomicListSchema), + equal: true, + }, + { + name: "atomic lists are not equal if order differs", + lhs: UnstructuredToVal([]interface{}{"a", "b"}, atomicListSchema), + rhs: UnstructuredToVal([]interface{}{"b", "a"}, atomicListSchema), + equal: false, + }, + { + name: "atomic lists are not equal if contents differ", + lhs: UnstructuredToVal([]interface{}{"a", "b"}, atomicListSchema), + rhs: UnstructuredToVal([]interface{}{"a", "c"}, atomicListSchema), + equal: false, + }, + { + name: "atomic lists are not equal if lengths differ", + lhs: UnstructuredToVal([]interface{}{"a", "b"}, atomicListSchema), + rhs: UnstructuredToVal([]interface{}{"a", "b", "c"}, atomicListSchema), + equal: false, + }, + { + name: "identical objects are equal", + lhs: UnstructuredToVal(map[string]interface{}{"field1": "a", "field2": "b"}, objectSchema), + rhs: UnstructuredToVal(map[string]interface{}{"field1": "a", "field2": "b"}, objectSchema), + equal: true, + }, + { + name: "objects are equal regardless of field order", + lhs: UnstructuredToVal(map[string]interface{}{"field1": "a", "field2": "b"}, objectSchema), + rhs: UnstructuredToVal(map[string]interface{}{"field2": "b", "field1": "a"}, objectSchema), + equal: true, + }, + { + name: "objects are not equal if contents differs", + lhs: UnstructuredToVal(map[string]interface{}{"field1": "a", "field2": "b"}, objectSchema), + rhs: UnstructuredToVal(map[string]interface{}{"field1": "a", "field2": "c"}, objectSchema), + equal: false, + }, + { + name: "objects are not equal if length differs", + lhs: UnstructuredToVal(map[string]interface{}{"field1": "a", "field2": "b"}, objectSchema), + rhs: UnstructuredToVal(map[string]interface{}{"field1": "a"}, objectSchema), + equal: false, + }, + { + name: "identical maps are equal", + lhs: UnstructuredToVal(map[string]interface{}{"key1": "a", "key2": "b"}, mapSchema), + rhs: UnstructuredToVal(map[string]interface{}{"key1": "a", "key2": "b"}, mapSchema), + equal: true, + }, + { + name: "maps are equal regardless of field order", + lhs: UnstructuredToVal(map[string]interface{}{"key1": "a", "key2": "b"}, mapSchema), + rhs: UnstructuredToVal(map[string]interface{}{"key2": "b", "key1": "a"}, mapSchema), + equal: true, + }, + { + name: "maps are not equal if contents differs", + lhs: UnstructuredToVal(map[string]interface{}{"key1": "a", "key2": "b"}, mapSchema), + rhs: UnstructuredToVal(map[string]interface{}{"key1": "a", "key2": "c"}, mapSchema), + equal: false, + }, + { + name: "maps are not equal if length differs", + lhs: UnstructuredToVal(map[string]interface{}{"key1": "a", "key2": "b"}, mapSchema), + rhs: UnstructuredToVal(map[string]interface{}{"key1": "a", "key2": "b", "key3": "c"}, mapSchema), + equal: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Compare types with schema against themselves + if tc.lhs.Equal(tc.rhs) != types.Bool(tc.equal) { + t.Errorf("expected Equals to return %v", tc.equal) + } + if tc.rhs.Equal(tc.lhs) != types.Bool(tc.equal) { + t.Errorf("expected Equals to return %v", tc.equal) + } + + // Compare types with schema against native types. This is slightly different than how + // CEL performs equality against data literals, but is a good sanity check. + if tc.lhs.Equal(types.DefaultTypeAdapter.NativeToValue(tc.rhs.Value())) != types.Bool(tc.equal) { + t.Errorf("expected unstructuredVal.Equals() to return %v", tc.equal) + } + if tc.rhs.Equal(types.DefaultTypeAdapter.NativeToValue(tc.lhs.Value())) != types.Bool(tc.equal) { + t.Errorf("expected unstructuredVal.Equals() to return %v", tc.equal) + } + }) + } +} + +func TestLister(t *testing.T) { + cases := []struct { + name string + unstructured []interface{} + schema *spec.Schema + itemSchema *spec.Schema + size int64 + notContains []ref.Val + addition []interface{} + expectAdded []interface{} + }{ + { + name: "map list", + unstructured: []interface{}{ + map[string]interface{}{ + "key": "a", + "val": 1, + }, + map[string]interface{}{ + "key": "b", + "val": 2, + }, + }, + schema: mapListSchema, + itemSchema: mapListElementSchema, + size: 2, + notContains: []ref.Val{ + UnstructuredToVal(map[string]interface{}{ + "key": "a", + "val": 2, + }, mapListElementSchema), + UnstructuredToVal(map[string]interface{}{ + "key": "c", + "val": 1, + }, mapListElementSchema), + }, + addition: []interface{}{ + map[string]interface{}{ + "key": "b", + "val": 3, + }, + map[string]interface{}{ + "key": "c", + "val": 4, + }, + }, + expectAdded: []interface{}{ + map[string]interface{}{ + "key": "a", + "val": 1, + }, + map[string]interface{}{ + "key": "b", + "val": 3, + }, + map[string]interface{}{ + "key": "c", + "val": 4, + }, + }, + }, + { + name: "set list", + unstructured: []interface{}{"a", "b"}, + schema: setListSchema, + itemSchema: stringSchema, + size: 2, + notContains: []ref.Val{UnstructuredToVal("c", stringSchema)}, + addition: []interface{}{"b", "c"}, + expectAdded: []interface{}{"a", "b", "c"}, + }, + { + name: "atomic list", + unstructured: []interface{}{"a", "b"}, + schema: atomicListSchema, + itemSchema: stringSchema, + size: 2, + notContains: []ref.Val{UnstructuredToVal("c", stringSchema)}, + addition: []interface{}{"b", "c"}, + expectAdded: []interface{}{"a", "b", "b", "c"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + lister := UnstructuredToVal(tc.unstructured, tc.schema).(traits.Lister) + if lister.Size().Value() != tc.size { + t.Errorf("Expected Size to return %d but got %d", tc.size, lister.Size().Value()) + } + iter := lister.Iterator() + for i := 0; i < int(tc.size); i++ { + get := lister.Get(types.Int(i)).Value() + if !reflect.DeepEqual(get, tc.unstructured[i]) { + t.Errorf("Expected Get to return %v for index %d but got %v", tc.unstructured[i], i, get) + } + if iter.HasNext() != types.True { + t.Error("Expected HasNext to return true") + } + next := iter.Next().Value() + if !reflect.DeepEqual(next, tc.unstructured[i]) { + t.Errorf("Expected Next to return %v for index %d but got %v", tc.unstructured[i], i, next) + } + } + if iter.HasNext() != types.False { + t.Error("Expected HasNext to return false") + } + for _, contains := range tc.unstructured { + if lister.Contains(UnstructuredToVal(contains, tc.itemSchema)) != types.True { + t.Errorf("Expected Contains to return true for %v", contains) + } + } + for _, notContains := range tc.notContains { + if lister.Contains(notContains) != types.False { + t.Errorf("Expected Contains to return false for %v", notContains) + } + } + + addition := UnstructuredToVal(tc.addition, tc.schema).(traits.Lister) + added := lister.Add(addition).Value() + if !reflect.DeepEqual(added, tc.expectAdded) { + t.Errorf("Expected Add to return %v but got %v", tc.expectAdded, added) + } + }) + } +} + +func TestMapper(t *testing.T) { + cases := []struct { + name string + unstructured map[string]interface{} + schema *spec.Schema + propertySchema func(key string) (*spec.Schema, bool) + size int64 + notContains []ref.Val + }{ + { + name: "object", + unstructured: map[string]interface{}{ + "field1": "a", + "field2": "b", + }, + schema: objectSchema, + propertySchema: func(key string) (*spec.Schema, bool) { + if s, ok := objectSchema.Properties[key]; ok { + return &s, true + } + return nil, false + }, + size: 2, + notContains: []ref.Val{ + UnstructuredToVal("field3", stringSchema), + }, + }, + { + name: "map", + unstructured: map[string]interface{}{ + "key1": "a", + "key2": "b", + }, + schema: mapSchema, + propertySchema: func(key string) (*spec.Schema, bool) { return mapSchema.AdditionalProperties.Schema, true }, + size: 2, + notContains: []ref.Val{ + UnstructuredToVal("key3", stringSchema), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mapper := UnstructuredToVal(tc.unstructured, tc.schema).(traits.Mapper) + if mapper.Size().Value() != tc.size { + t.Errorf("Expected Size to return %d but got %d", tc.size, mapper.Size().Value()) + } + iter := mapper.Iterator() + iterResults := map[interface{}]struct{}{} + keys := map[interface{}]struct{}{} + for k := range tc.unstructured { + keys[k] = struct{}{} + get := mapper.Get(types.String(k)).Value() + if !reflect.DeepEqual(get, tc.unstructured[k]) { + t.Errorf("Expected Get to return %v for key %s but got %v", tc.unstructured[k], k, get) + } + if iter.HasNext() != types.True { + t.Error("Expected HasNext to return true") + } + iterResults[iter.Next().Value()] = struct{}{} + } + if !reflect.DeepEqual(iterResults, keys) { + t.Errorf("Expected accumulation of iterator.Next calls to be %v but got %v", keys, iterResults) + } + if iter.HasNext() != types.False { + t.Error("Expected HasNext to return false") + } + for contains := range tc.unstructured { + if mapper.Contains(UnstructuredToVal(contains, stringSchema)) != types.True { + t.Errorf("Expected Contains to return true for %v", contains) + } + } + for _, notContains := range tc.notContains { + if mapper.Contains(notContains) != types.False { + t.Errorf("Expected Contains to return false for %v", notContains) + } + } + }) + } +} + +func BenchmarkUnstructuredToVal(b *testing.B) { + u := []interface{}{ + map[string]interface{}{ + "key": "a", + "val": 1, + }, + map[string]interface{}{ + "key": "b", + "val": 2, + }, + map[string]interface{}{ + "key": "@b", + "val": 2, + }, + } + + b.ReportAllocs() + b.ResetTimer() + + for n := 0; n < b.N; n++ { + if val := UnstructuredToVal(u, mapListSchema); val == nil { + b.Fatal(val) + } + } +} + +func BenchmarkUnstructuredToValWithEscape(b *testing.B) { + u := []interface{}{ + map[string]interface{}{ + "key": "a.1", + "val": "__i.1", + }, + map[string]interface{}{ + "key": "b.1", + "val": 2, + }, + } + + b.ReportAllocs() + b.ResetTimer() + + for n := 0; n < b.N; n++ { + if val := UnstructuredToVal(u, mapListSchema); val == nil { + b.Fatal(val) + } + } +} From 26089a4c957a87c27da31ecbf171e4943f5af6c0 Mon Sep 17 00:00:00 2001 From: Jiahui Feng Date: Wed, 14 Dec 2022 09:18:54 -0800 Subject: [PATCH 2/8] implement OpenAPI-based schema resolver. --- .../pkg/cel/openapi/resolver/definitions.go | 141 ++++++++++++++++ .../pkg/cel/openapi/resolver/discovery.go | 153 ++++++++++++++++++ .../pkg/cel/openapi/resolver/resolver.go | 39 +++++ 3 files changed, 333 insertions(+) create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/definitions.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/discovery.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/resolver.go diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/definitions.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/definitions.go new file mode 100644 index 00000000000..170b2c86b39 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/definitions.go @@ -0,0 +1,141 @@ +/* +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 resolver + +import ( + "encoding/json" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/openapi" + "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// DefinitionsSchemaResolver resolves the schema of a built-in type +// by looking up the OpenAPI definitions. +type DefinitionsSchemaResolver struct { + defs map[string]common.OpenAPIDefinition + gvkToSchema map[schema.GroupVersionKind]*spec.Schema +} + +// NewDefinitionsSchemaResolver creates a new DefinitionsSchemaResolver. +// An example working setup: +// scheme = "k8s.io/client-go/kubernetes/scheme".Scheme +// getDefinitions = "k8s.io/kubernetes/pkg/generated/openapi".GetOpenAPIDefinitions +func NewDefinitionsSchemaResolver(scheme *runtime.Scheme, getDefinitions common.GetOpenAPIDefinitions) *DefinitionsSchemaResolver { + gvkToSchema := make(map[schema.GroupVersionKind]*spec.Schema) + namer := openapi.NewDefinitionNamer(scheme) + defs := getDefinitions(func(path string) spec.Ref { + return spec.MustCreateRef(path) + }) + for name, def := range defs { + _, e := namer.GetDefinitionName(name) + gvks := extensionsToGVKs(e) + for _, gvk := range gvks { + gvkToSchema[gvk] = &def.Schema + } + } + return &DefinitionsSchemaResolver{ + gvkToSchema: gvkToSchema, + defs: defs, + } +} + +func (d *DefinitionsSchemaResolver) ResolveSchema(gvk schema.GroupVersionKind) (*spec.Schema, error) { + s, ok := d.gvkToSchema[gvk] + if !ok { + return nil, fmt.Errorf("cannot resolve %v: %w", gvk, ErrSchemaNotFound) + } + result, err := deepCopy(s) + if err != nil { + return nil, fmt.Errorf("cannot deep copy schema for %v: %v", gvk, err) + } + err = populateRefs(func(ref string) (*spec.Schema, bool) { + // find the schema by the ref string, and return a deep copy + def, ok := d.defs[ref] + if !ok { + return nil, false + } + s, err := deepCopy(&def.Schema) + if err != nil { + return nil, false + } + return s, true + }, result) + if err != nil { + return nil, err + } + return result, nil +} + +// deepCopy generates a deep copy of the given schema with JSON marshalling and +// unmarshalling. +// The schema is expected to be "shallow", with all its field being Refs instead +// of nested schemas. +// If the schema contains cyclic reference, for example, a properties is itself +// it will return an error. This resolver does not support such condition. +func deepCopy(s *spec.Schema) (*spec.Schema, error) { + b, err := json.Marshal(s) + if err != nil { + return nil, err + } + result := new(spec.Schema) + err = json.Unmarshal(b, result) + if err != nil { + return nil, err + } + return result, nil +} + +func extensionsToGVKs(extensions spec.Extensions) []schema.GroupVersionKind { + gvksAny, ok := extensions[extGVK] + if !ok { + return nil + } + gvks, ok := gvksAny.([]any) + if !ok { + return nil + } + result := make([]schema.GroupVersionKind, 0, len(gvks)) + for _, gvkAny := range gvks { + // type check the map and all fields + gvkMap, ok := gvkAny.(map[string]any) + if !ok { + return nil + } + g, ok := gvkMap["group"].(string) + if !ok { + return nil + } + v, ok := gvkMap["version"].(string) + if !ok { + return nil + } + k, ok := gvkMap["kind"].(string) + if !ok { + return nil + } + result = append(result, schema.GroupVersionKind{ + Group: g, + Version: v, + Kind: k, + }) + } + return result +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/discovery.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/discovery.go new file mode 100644 index 00000000000..75f9fd6dc99 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/discovery.go @@ -0,0 +1,153 @@ +/* +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 resolver + +import ( + "encoding/json" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// ClientDiscoveryResolver uses client-go discovery to resolve schemas at run time. +type ClientDiscoveryResolver struct { + Discovery discovery.DiscoveryInterface +} + +var _ SchemaResolver = (*ClientDiscoveryResolver)(nil) + +func (r *ClientDiscoveryResolver) ResolveSchema(gvk schema.GroupVersionKind) (*spec.Schema, error) { + p, err := r.Discovery.OpenAPIV3().Paths() + if err != nil { + return nil, err + } + resourcePath := resourcePathFromGV(gvk.GroupVersion()) + c, ok := p[resourcePath] + if !ok { + return nil, fmt.Errorf("cannot resolve group version %q: %w", gvk.GroupVersion(), ErrSchemaNotFound) + } + b, err := c.Schema(runtime.ContentTypeJSON) + if err != nil { + return nil, err + } + resp := new(schemaResponse) + err = json.Unmarshal(b, resp) + if err != nil { + return nil, err + } + s, err := resolveType(resp, gvk) + if err != nil { + return nil, err + } + err = populateRefs(func(ref string) (*spec.Schema, bool) { + s, ok := resp.Components.Schemas[strings.TrimPrefix(ref, refPrefix)] + return s, ok + }, s) + if err != nil { + return nil, err + } + return s, nil +} + +func populateRefs(schemaOf func(ref string) (*spec.Schema, bool), schema *spec.Schema) error { + ref, isRef := refOf(schema) + if isRef { + // replace the whole schema with the referred one. + resolved, ok := schemaOf(ref) + if !ok { + return fmt.Errorf("internal error: cannot resolve Ref %q: %w", ref, ErrSchemaNotFound) + } + *schema = *resolved + } + // schema is an object, populate its properties and additionalProperties + for name, prop := range schema.Properties { + err := populateRefs(schemaOf, &prop) + if err != nil { + return err + } + schema.Properties[name] = prop + } + if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { + err := populateRefs(schemaOf, schema.AdditionalProperties.Schema) + if err != nil { + return err + } + } + // schema is a list, populate its items + if schema.Items != nil && schema.Items.Schema != nil { + err := populateRefs(schemaOf, schema.Items.Schema) + if err != nil { + return err + } + } + return nil +} + +func refOf(schema *spec.Schema) (string, bool) { + if schema.Ref.GetURL() != nil { + return schema.Ref.String(), true + } + // A Ref may be wrapped in allOf to preserve its description + // see https://github.com/kubernetes/kubernetes/issues/106387 + // For kube-openapi, allOf is only used for wrapping a Ref. + for _, allOf := range schema.AllOf { + if ref, isRef := refOf(&allOf); isRef { + return ref, isRef + } + } + return "", false +} + +func resolveType(resp *schemaResponse, gvk schema.GroupVersionKind) (*spec.Schema, error) { + for _, s := range resp.Components.Schemas { + var gvks []schema.GroupVersionKind + err := s.Extensions.GetObject(extGVK, &gvks) + if err != nil { + return nil, err + } + for _, g := range gvks { + if g == gvk { + return s, nil + } + } + } + return nil, fmt.Errorf("cannot resolve group version kind %q: %w", gvk, ErrSchemaNotFound) +} + +func resourcePathFromGV(gv schema.GroupVersion) string { + var resourcePath string + if len(gv.Group) == 0 { + resourcePath = fmt.Sprintf("api/%s", gv.Version) + } else { + resourcePath = fmt.Sprintf("apis/%s/%s", gv.Group, gv.Version) + } + return resourcePath +} + +type schemaResponse struct { + Components struct { + Schemas map[string]*spec.Schema `json:"schemas"` + } `json:"components"` +} + +const refPrefix = "#/components/schemas/" + +const extGVK = "x-kubernetes-group-version-kind" diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/resolver.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/resolver.go new file mode 100644 index 00000000000..4060c86cefc --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/resolver.go @@ -0,0 +1,39 @@ +/* +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 resolver + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// SchemaResolver finds the OpenAPI schema for the given GroupVersionKind. +// This interface uses the type defined by k8s.io/kube-openapi +type SchemaResolver interface { + // ResolveSchema takes a GroupVersionKind (GVK) and returns the OpenAPI schema + // identified by the GVK. + // The function returns a non-nil error if the schema cannot be found or fail + // to resolve. The returned error wraps ErrSchemaNotFound if the resolution is + // attempted but the corresponding schema cannot be found. + ResolveSchema(gvk schema.GroupVersionKind) (*spec.Schema, error) +} + +// ErrSchemaNotFound is wrapped and returned if the schema cannot be located +// by the resolver. +var ErrSchemaNotFound = fmt.Errorf("schema not found") From 43ef87a2680ecc007a036ca032ee17e294aff55c Mon Sep 17 00:00:00 2001 From: Jiahui Feng Date: Wed, 14 Dec 2022 09:19:25 -0800 Subject: [PATCH 3/8] create OWNERS file for cel libs. --- staging/src/k8s.io/apiserver/pkg/cel/OWNERS | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/OWNERS diff --git a/staging/src/k8s.io/apiserver/pkg/cel/OWNERS b/staging/src/k8s.io/apiserver/pkg/cel/OWNERS new file mode 100644 index 00000000000..f550fc17944 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/OWNERS @@ -0,0 +1,11 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +# Kubernetes CEL library authors and maintainers +approvers: + - jpbetz + - cici37 + - jiahuif +reviewers: + - jpbetz + - cici37 + - jiahuif From 5c6d8a939c2dff117235f9ac134979fcace892ca Mon Sep 17 00:00:00 2001 From: Jiahui Feng Date: Wed, 14 Dec 2022 09:19:36 -0800 Subject: [PATCH 4/8] add int. test for CEL type resolution. --- .../apiserver/cel/typeresolution_test.go | 516 ++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 test/integration/apiserver/cel/typeresolution_test.go diff --git a/test/integration/apiserver/cel/typeresolution_test.go b/test/integration/apiserver/cel/typeresolution_test.go new file mode 100644 index 00000000000..a58bc82bc71 --- /dev/null +++ b/test/integration/apiserver/cel/typeresolution_test.go @@ -0,0 +1,516 @@ +/* +Copyright 2022 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 cel + +import ( + "context" + "reflect" + "strings" + "testing" + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/interpreter" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + apiv1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + nodev1 "k8s.io/api/node/v1" + storagev1 "k8s.io/api/storage/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + extclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/wait" + commoncel "k8s.io/apiserver/pkg/cel" + "k8s.io/apiserver/pkg/cel/library" + celopenapi "k8s.io/apiserver/pkg/cel/openapi" + "k8s.io/apiserver/pkg/cel/openapi/resolver" + k8sscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/kube-openapi/pkg/validation/spec" + apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" + corev1 "k8s.io/kubernetes/pkg/apis/core/v1" + "k8s.io/kubernetes/pkg/generated/openapi" + "k8s.io/kubernetes/test/integration/framework" + "k8s.io/utils/pointer" +) + +func TestTypeResolver(t *testing.T) { + server, err := apiservertesting.StartTestServer(t, nil, nil, framework.SharedEtcd()) + if err != nil { + t.Fatal(err) + } + defer server.TearDownFn() + + config := server.ClientConfig + + client, err := extclientset.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + + crd, err := installCRD(client) + if err != nil { + t.Fatal(err) + } + defer func(crd *apiextensionsv1.CustomResourceDefinition) { + err := client.ApiextensionsV1().CustomResourceDefinitions().Delete(context.Background(), crd.Name, metav1.DeleteOptions{}) + if err != nil { + t.Fatal(err) + } + }(crd) + discoveryResolver := &resolver.ClientDiscoveryResolver{Discovery: client.Discovery()} + definitionsResolver := resolver.NewDefinitionsSchemaResolver(k8sscheme.Scheme, openapi.GetOpenAPIDefinitions) + // wait until the CRD schema is published at the OpenAPI v3 endpoint + err = wait.PollImmediate(time.Second, time.Minute, func() (done bool, err error) { + p, err := client.OpenAPIV3().Paths() + if err != nil { + return + } + if _, ok := p["apis/apis.example.com/v1beta1"]; ok { + return true, nil + } + return false, nil + }) + if err != nil { + t.Fatalf("timeout wait for CRD schema publication: %v", err) + } + + for _, tc := range []struct { + name string + obj runtime.Object + expression string + expectResolutionErr bool + expectCompileErr bool + expectEvalErr bool + expectedResult any + resolvers []resolver.SchemaResolver + }{ + { + name: "unknown type", + obj: &unstructured.Unstructured{Object: map[string]any{ + "kind": "Bad", + "apiVersion": "bad.example.com/v1", + }}, + expectResolutionErr: true, + resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver}, + }, + { + name: "deployment", + obj: sampleReplicatedDeployment(), + expression: "self.spec.replicas > 1", + expectResolutionErr: false, + expectCompileErr: false, + expectEvalErr: false, + resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver}, + + // expect a boolean, which is `true`. + expectedResult: true, + }, + { + name: "missing field", + obj: sampleReplicatedDeployment(), + expression: "self.spec.missing > 1", + expectResolutionErr: false, + expectCompileErr: true, + resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver}, + }, + { + name: "mistyped expression", + obj: sampleReplicatedDeployment(), + expression: "self.spec.replicas == '1'", + expectResolutionErr: false, + expectCompileErr: true, + resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver}, + }, + { + name: "crd valid", + obj: &unstructured.Unstructured{Object: map[string]any{ + "kind": "CronTab", + "apiVersion": "apis.example.com/v1beta1", + "spec": map[string]any{ + "cronSpec": "* * * * *", + "image": "foo-image", + "replicas": 2, + }, + }}, + expression: "self.spec.replicas > 1", + expectResolutionErr: false, + expectCompileErr: false, + expectEvalErr: false, + resolvers: []resolver.SchemaResolver{discoveryResolver}, + + // expect a boolean, which is `true`. + expectedResult: true, + }, + { + name: "crd missing field", + obj: &unstructured.Unstructured{Object: map[string]any{ + "kind": "CronTab", + "apiVersion": "apis.example.com/v1beta1", + "spec": map[string]any{ + "cronSpec": "* * * * *", + "image": "foo-image", + "replicas": 2, + }, + }}, + expression: "self.spec.missing > 1", + expectResolutionErr: false, + expectCompileErr: true, + resolvers: []resolver.SchemaResolver{discoveryResolver}, + }, + { + name: "crd mistyped", + obj: &unstructured.Unstructured{Object: map[string]any{ + "kind": "CronTab", + "apiVersion": "apis.example.com/v1beta1", + "spec": map[string]any{ + "cronSpec": "* * * * *", + "image": "foo-image", + "replicas": 2, + }, + }}, + expression: "self.spec.replica == '1'", + expectResolutionErr: false, + expectCompileErr: true, + resolvers: []resolver.SchemaResolver{discoveryResolver}, + }, + { + name: "items population", + obj: sampleReplicatedDeployment(), + // `containers` is an array whose items are of `Container` type + // `ports` is an array of `ContainerPort` + expression: "size(self.spec.template.spec.containers) > 0 &&" + + "self.spec.template.spec.containers.all(c, c.ports.all(p, p.containerPort < 1024))", + expectResolutionErr: false, + expectCompileErr: false, + expectEvalErr: false, + expectedResult: true, + resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver}, + }, + { + name: "int-or-string int", + obj: &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: "apps/v1", + }, + Spec: appsv1.DeploymentSpec{ + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxSurge: &intstr.IntOrString{Type: intstr.Int, IntVal: 5}, + }, + }, + }, + }, + expression: "has(self.spec.strategy.rollingUpdate) &&" + + "type(self.spec.strategy.rollingUpdate.maxSurge) == int &&" + + "self.spec.strategy.rollingUpdate.maxSurge > 1", + expectResolutionErr: false, + expectCompileErr: false, + expectEvalErr: false, + expectedResult: true, + resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver}, + }, + { + name: "int-or-string string", + obj: &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: "apps/v1", + }, + Spec: appsv1.DeploymentSpec{ + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxSurge: &intstr.IntOrString{Type: intstr.String, StrVal: "10%"}, + }, + }, + }, + }, + expression: "has(self.spec.strategy.rollingUpdate) &&" + + "type(self.spec.strategy.rollingUpdate.maxSurge) == string &&" + + "self.spec.strategy.rollingUpdate.maxSurge == '10%'", + expectResolutionErr: false, + expectCompileErr: false, + expectEvalErr: false, + expectedResult: true, + resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + gvk := tc.obj.GetObjectKind().GroupVersionKind() + var s *spec.Schema + for _, r := range tc.resolvers { + var err error + s, err = r.ResolveSchema(gvk) + if err != nil { + if tc.expectResolutionErr { + return + } + t.Fatalf("cannot resolve type: %v", err) + } + if tc.expectResolutionErr { + t.Fatalf("expected resulution error but got none") + } + } + program, err := simpleCompileCEL(s, tc.expression) + if err != nil { + if tc.expectCompileErr { + return + } + t.Fatalf("cannot eval: %v", err) + } + if tc.expectCompileErr { + t.Fatalf("expected compilation error but got none") + } + unstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.obj) + if err != nil { + t.Fatal(err) + } + ret, _, err := program.Eval(&simpleActivation{self: celopenapi.UnstructuredToVal(unstructured, s)}) + if err != nil { + if tc.expectEvalErr { + return + } + t.Fatalf("cannot eval: %v", err) + } + if tc.expectEvalErr { + t.Fatalf("expected eval error but got none") + } + if !reflect.DeepEqual(ret.Value(), tc.expectedResult) { + t.Errorf("wrong result, expected %q but got %q", tc.expectedResult, ret) + } + }) + } + +} + +// TestBuiltinResolution asserts that all resolver implementations should +// resolve Kubernetes built-in types without error. +func TestBuiltinResolution(t *testing.T) { + // before all, setup server and client + server, err := apiservertesting.StartTestServer(t, nil, nil, framework.SharedEtcd()) + if err != nil { + t.Fatal(err) + } + defer server.TearDownFn() + + config := server.ClientConfig + + client, err := extclientset.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + resolver resolver.SchemaResolver + scheme *runtime.Scheme + }{ + { + name: "definitions", + resolver: resolver.NewDefinitionsSchemaResolver(k8sscheme.Scheme, openapi.GetOpenAPIDefinitions), + scheme: buildTestScheme(), + }, + { + name: "discovery", + resolver: &resolver.ClientDiscoveryResolver{Discovery: client.Discovery()}, + scheme: buildTestScheme(), + }, + } { + t.Run(tc.name, func(t *testing.T) { + for gvk := range tc.scheme.AllKnownTypes() { + // skip aliases to metav1 + if gvk.Kind == "APIGroup" || gvk.Kind == "APIGroupList" || gvk.Kind == "APIVersions" || + strings.HasSuffix(gvk.Kind, "Options") || strings.HasSuffix(gvk.Kind, "Event") { + continue + } + // skip private, reference, and alias types that cannot appear in the wild + if gvk.Kind == "SerializedReference" || gvk.Kind == "List" || gvk.Kind == "RangeAllocation" || gvk.Kind == "PodStatusResult" { + continue + } + // skip internal types + if gvk.Version == "__internal" { + continue + } + _, err = tc.resolver.ResolveSchema(gvk) + if err != nil { + t.Errorf("resolver %q cannot resolve %v", tc.name, gvk) + } + } + }) + } +} + +// simpleCompileCEL compiles the CEL expression against the schema +// with the practical defaults. +// `self` is defined as the object being evaluated against. +func simpleCompileCEL(schema *spec.Schema, expression string) (cel.Program, error) { + var opts []cel.EnvOption + opts = append(opts, cel.HomogeneousAggregateLiterals()) + opts = append(opts, cel.EagerlyValidateDeclarations(true), cel.DefaultUTCTimeZone(true)) + opts = append(opts, library.ExtensionLibs...) + env, err := cel.NewEnv(opts...) + if err != nil { + return nil, err + } + reg := commoncel.NewRegistry(env) + declType := celopenapi.SchemaDeclType(schema, true) + rt, err := commoncel.NewRuleTypes("selfType", declType, reg) + if err != nil { + return nil, err + } + opts, err = rt.EnvOptions(env.TypeProvider()) + if err != nil { + return nil, err + } + rootType, _ := rt.FindDeclType("selfType") + opts = append(opts, cel.Variable("self", rootType.CelType())) + env, err = env.Extend(opts...) + if err != nil { + return nil, err + } + ast, issues := env.Compile(expression) + if issues != nil { + return nil, issues.Err() + } + return env.Program(ast) +} + +// sampleReplicatedDeployment returns a sample Deployment with 2 replicas. +// The object is not inlined because the schema of Deployment is well-known +// and thus requires no reference when reading the test cases. +func sampleReplicatedDeployment() *appsv1.Deployment { + return &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: "apps/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "demo-deployment", + }, + Spec: appsv1.DeploymentSpec{ + Replicas: pointer.Int32(2), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "demo", + }, + }, + Template: apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "demo", + }, + }, + Spec: apiv1.PodSpec{ + Containers: []apiv1.Container{ + { + Name: "web", + Image: "nginx", + Ports: []apiv1.ContainerPort{ + { + Name: "http", + Protocol: apiv1.ProtocolTCP, + ContainerPort: 80, + }, + }, + }, + }, + }, + }, + }, + } +} + +func installCRD(apiExtensionClient extclientset.Interface) (*apiextensionsv1.CustomResourceDefinition, error) { + // CRD borrowed from https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/ + crd := &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crontabs.apis.example.com", + }, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Group: "apis.example.com", + Scope: apiextensionsv1.NamespaceScoped, + Names: apiextensionsv1.CustomResourceDefinitionNames{ + Plural: "crontabs", + Singular: "crontab", + Kind: "CronTab", + ListKind: "CronTabList", + }, + Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ + { + Name: "v1beta1", + Served: true, + Storage: true, + Schema: &apiextensionsv1.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ + XPreserveUnknownFields: pointer.Bool(true), + Type: "object", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "cronSpec": {Type: "string"}, + "image": {Type: "string"}, + "replicas": {Type: "integer"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + return apiExtensionClient.ApiextensionsV1(). + CustomResourceDefinitions().Create(context.Background(), crd, metav1.CreateOptions{}) +} + +type simpleActivation struct { + self any +} + +func (a *simpleActivation) ResolveName(name string) (interface{}, bool) { + switch name { + case "self": + return a.self, true + default: + return nil, false + } +} + +func (a *simpleActivation) Parent() interpreter.Activation { + return nil +} + +func buildTestScheme() *runtime.Scheme { + // hand-picked schemes that the test API server serves + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = admissionregistrationv1.AddToScheme(scheme) + _ = networkingv1.AddToScheme(scheme) + _ = nodev1.AddToScheme(scheme) + _ = storagev1.AddToScheme(scheme) + return scheme +} From e11ea145eaa9c3261a3a6c3ef8c9c5ff29ad5e56 Mon Sep 17 00:00:00 2001 From: Jiahui Feng Date: Wed, 11 Jan 2023 10:08:11 -0800 Subject: [PATCH 5/8] make structural type use OpenAPI lib. --- .../pkg/apiserver/schema/cel/compilation.go | 18 +- .../pkg/apiserver/schema/cel/maplist.go | 155 +--- .../pkg/apiserver/schema/cel/maplist_test.go | 4 +- .../pkg/apiserver/schema/cel/model/schemas.go | 205 +----- .../pkg/apiserver/schema/cel/validation.go | 2 +- .../pkg/apiserver/schema/cel/values.go | 678 +----------------- .../apiserver/pkg/cel/openapi/maplist.go | 20 +- .../apiserver/pkg/cel/openapi/maplist_test.go | 6 +- 8 files changed, 40 insertions(+), 1048 deletions(-) diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation.go index 5ead877b803..0b76b41291a 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation.go @@ -27,7 +27,6 @@ import ( apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" - celmodel "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model" apiservercel "k8s.io/apiserver/pkg/cel" "k8s.io/apiserver/pkg/cel/library" "k8s.io/apiserver/pkg/cel/metrics" @@ -53,6 +52,10 @@ const ( // checkFrequency configures the number of iterations within a comprehension to evaluate // before checking whether the function evaluation has been interrupted checkFrequency = 100 + + // maxRequestSizeBytes is the maximum size of a request to the API server + // TODO(DangerOnTheRanger): wire in MaxRequestBodyBytes from apiserver/pkg/server/options/server_run_options.go to make this configurable + maxRequestSizeBytes = apiservercel.DefaultMaxRequestSizeBytes ) // CompilationResult represents the cel compilation result for one rule @@ -149,7 +152,7 @@ func Compile(s *schema.Structural, declType *apiservercel.DeclType, perCallLimit estimator := newCostEstimator(root) // compResults is the return value which saves a list of compilation results in the same order as x-kubernetes-validations rules. compResults := make([]CompilationResult, len(celRules)) - maxCardinality := celmodel.MaxCardinality(root.MinSerializedSize) + maxCardinality := maxCardinality(root.MinSerializedSize) for i, rule := range celRules { compResults[i] = compileRule(rule, env, perCallLimit, estimator, maxCardinality) } @@ -262,3 +265,14 @@ func (c *sizeEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstim func (c *sizeEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { return nil } + +// maxCardinality returns the maximum number of times data conforming to the minimum size given could possibly exist in +// an object serialized to JSON. For cases where a schema is contained under map or array schemas of unbounded +// size, this can be used as an estimate as the worst case number of times data matching the schema could be repeated. +// Note that this only assumes a single comma between data elements, so if the schema is contained under only maps, +// this estimates a higher cardinality that would be possible. DeclType.MinSerializedSize is meant to be passed to +// this function. +func maxCardinality(minSize int64) uint64 { + sz := minSize + 1 // assume at least one comma between elements + return uint64(maxRequestSizeBytes / sz) +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist.go index 7e26ad956fc..99d9f505914 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist.go @@ -17,162 +17,13 @@ limitations under the License. package cel import ( - "fmt" - "strings" - "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + celopenapi "k8s.io/apiserver/pkg/cel/openapi" ) -// mapList provides a "lookup by key" operation for lists (arrays) with x-kubernetes-list-type=map. -type mapList interface { - // get returns the first element having given key, for all - // x-kubernetes-list-map-keys, to the provided object. If the provided object isn't itself a valid mapList element, - // get returns nil. - get(interface{}) interface{} -} - -type keyStrategy interface { - // CompositeKeyFor returns a composite key for the provided object, if possible, and a - // boolean that indicates whether or not a key could be generated for the provided object. - CompositeKeyFor(map[string]interface{}) (interface{}, bool) -} - -// singleKeyStrategy is a cheaper strategy for associative lists that have exactly one key. -type singleKeyStrategy struct { - key string -} - -// CompositeKeyFor directly returns the value of the single key to -// use as a composite key. -func (ks *singleKeyStrategy) CompositeKeyFor(obj map[string]interface{}) (interface{}, bool) { - v, ok := obj[ks.key] - if !ok { - return nil, false - } - - switch v.(type) { - case bool, float64, int64, string: - return v, true - default: - return nil, false // non-scalar - } -} - -// multiKeyStrategy computes a composite key of all key values. -type multiKeyStrategy struct { - sts *schema.Structural -} - -// CompositeKeyFor returns a composite key computed from the values of all -// keys. -func (ks *multiKeyStrategy) CompositeKeyFor(obj map[string]interface{}) (interface{}, bool) { - const keyDelimiter = "\x00" // 0 byte should never appear in the composite key except as delimiter - - var delimited strings.Builder - for _, key := range ks.sts.XListMapKeys { - v, ok := obj[key] - if !ok { - return nil, false - } - - switch v.(type) { - case bool: - fmt.Fprintf(&delimited, keyDelimiter+"%t", v) - case float64: - fmt.Fprintf(&delimited, keyDelimiter+"%f", v) - case int64: - fmt.Fprintf(&delimited, keyDelimiter+"%d", v) - case string: - fmt.Fprintf(&delimited, keyDelimiter+"%q", v) - default: - return nil, false // values must be scalars - } - } - return delimited.String(), true -} - -// emptyMapList is a mapList containing no elements. -type emptyMapList struct{} - -func (emptyMapList) get(interface{}) interface{} { - return nil -} - -type mapListImpl struct { - sts *schema.Structural - ks keyStrategy - // keyedItems contains all lazily keyed map items - keyedItems map[interface{}]interface{} - // unkeyedItems contains all map items that have not yet been keyed - unkeyedItems []interface{} -} - -func (a *mapListImpl) get(obj interface{}) interface{} { - mobj, ok := obj.(map[string]interface{}) - if !ok { - return nil - } - - key, ok := a.ks.CompositeKeyFor(mobj) - if !ok { - return nil - } - if match, ok := a.keyedItems[key]; ok { - return match - } - // keep keying items until we either find a match or run out of unkeyed items - for len(a.unkeyedItems) > 0 { - // dequeue an unkeyed item - item := a.unkeyedItems[0] - a.unkeyedItems = a.unkeyedItems[1:] - - // key the item - mitem, ok := item.(map[string]interface{}) - if !ok { - continue - } - itemKey, ok := a.ks.CompositeKeyFor(mitem) - if !ok { - continue - } - if _, exists := a.keyedItems[itemKey]; !exists { - a.keyedItems[itemKey] = mitem - } - - // if it matches, short-circuit - if itemKey == key { - return mitem - } - } - - return nil -} - -func makeKeyStrategy(sts *schema.Structural) keyStrategy { - if len(sts.XListMapKeys) == 1 { - key := sts.XListMapKeys[0] - return &singleKeyStrategy{ - key: key, - } - } - - return &multiKeyStrategy{ - sts: sts, - } -} - // makeMapList returns a queryable interface over the provided x-kubernetes-list-type=map // keyedItems. If the provided schema is _not_ an array with x-kubernetes-list-type=map, returns an // empty mapList. -func makeMapList(sts *schema.Structural, items []interface{}) (rv mapList) { - if sts.Type != "array" || sts.XListType == nil || *sts.XListType != "map" || len(sts.XListMapKeys) == 0 || len(items) == 0 { - return emptyMapList{} - } - ks := makeKeyStrategy(sts) - return &mapListImpl{ - sts: sts, - ks: ks, - keyedItems: map[interface{}]interface{}{}, - unkeyedItems: items, - } +func makeMapList(sts *schema.Structural, items []interface{}) (rv celopenapi.MapList) { + return celopenapi.MakeMapList(sts.ToKubeOpenAPI(), items) } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist_test.go index 97c2b80f76e..85ad0ae1f34 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist_test.go @@ -323,9 +323,9 @@ func TestMapList(t *testing.T) { t.Run(tc.name, func(t *testing.T) { mapList := makeMapList(&tc.sts, tc.items) for _, warmUp := range tc.warmUpQueries { - mapList.get(warmUp) + mapList.Get(warmUp) } - actual := mapList.get(tc.query) + actual := mapList.Get(tc.query) if !reflect.DeepEqual(tc.expected, actual) { t.Errorf("got: %v, expected %v", actual, tc.expected) } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/schemas.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/schemas.go index b9b47caea50..1bd8713026f 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/schemas.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/schemas.go @@ -17,19 +17,12 @@ limitations under the License. package model import ( - "time" - - "github.com/google/cel-go/cel" - "github.com/google/cel-go/common/types" - apiservercel "k8s.io/apiserver/pkg/cel" + celopenapi "k8s.io/apiserver/pkg/cel/openapi" "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" ) -// TODO(DangerOnTheRanger): wire in MaxRequestBodyBytes from apiserver/pkg/server/options/server_run_options.go to make this configurable -const maxRequestSizeBytes = apiservercel.DefaultMaxRequestSizeBytes - // SchemaDeclType converts the structural schema to a CEL declaration, or returns nil if the // structural schema should not be exposed in CEL expressions. // Set isResourceRoot to true for the root of a custom resource or embedded resource. @@ -40,152 +33,7 @@ const maxRequestSizeBytes = apiservercel.DefaultMaxRequestSizeBytes // // The CEL declaration for objects with XPreserveUnknownFields does not expose unknown fields. func SchemaDeclType(s *schema.Structural, isResourceRoot bool) *apiservercel.DeclType { - if s == nil { - return nil - } - if s.XIntOrString { - // schemas using XIntOrString are not required to have a type. - - // intOrStringType represents the x-kubernetes-int-or-string union type in CEL expressions. - // In CEL, the type is represented as dynamic value, which can be thought of as a union type of all types. - // All type checking for XIntOrString is deferred to runtime, so all access to values of this type must - // be guarded with a type check, e.g.: - // - // To require that the string representation be a percentage: - // `type(intOrStringField) == string && intOrStringField.matches(r'(\d+(\.\d+)?%)')` - // To validate requirements on both the int and string representation: - // `type(intOrStringField) == int ? intOrStringField < 5 : double(intOrStringField.replace('%', '')) < 0.5 - // - dyn := apiservercel.NewSimpleTypeWithMinSize("dyn", cel.DynType, nil, 1) // smallest value for a serialized x-kubernetes-int-or-string is 0 - // handle x-kubernetes-int-or-string by returning the max length/min serialized size of the largest possible string - dyn.MaxElements = maxRequestSizeBytes - 2 - return dyn - } - - // We ignore XPreserveUnknownFields since we don't support validation rules on - // data that we don't have schema information for. - - if isResourceRoot { - // 'apiVersion', 'kind', 'metadata.name' and 'metadata.generateName' are always accessible to validator rules - // at the root of resources, even if not specified in the schema. - // This includes the root of a custom resource and the root of XEmbeddedResource objects. - s = WithTypeAndObjectMeta(s) - } - - switch s.Type { - case "array": - if s.Items != nil { - itemsType := SchemaDeclType(s.Items, s.Items.XEmbeddedResource) - if itemsType == nil { - return nil - } - var maxItems int64 - if s.ValueValidation != nil && s.ValueValidation.MaxItems != nil { - maxItems = zeroIfNegative(*s.ValueValidation.MaxItems) - } else { - maxItems = estimateMaxArrayItemsFromMinSize(itemsType.MinSerializedSize) - } - return apiservercel.NewListType(itemsType, maxItems) - } - return nil - case "object": - if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil { - propsType := SchemaDeclType(s.AdditionalProperties.Structural, s.AdditionalProperties.Structural.XEmbeddedResource) - if propsType != nil { - var maxProperties int64 - if s.ValueValidation != nil && s.ValueValidation.MaxProperties != nil { - maxProperties = zeroIfNegative(*s.ValueValidation.MaxProperties) - } else { - maxProperties = estimateMaxAdditionalPropertiesFromMinSize(propsType.MinSerializedSize) - } - return apiservercel.NewMapType(apiservercel.StringType, propsType, maxProperties) - } - return nil - } - fields := make(map[string]*apiservercel.DeclField, len(s.Properties)) - - required := map[string]bool{} - if s.ValueValidation != nil { - for _, f := range s.ValueValidation.Required { - required[f] = true - } - } - // an object will always be serialized at least as {}, so account for that - minSerializedSize := int64(2) - for name, prop := range s.Properties { - var enumValues []interface{} - if prop.ValueValidation != nil { - for _, e := range prop.ValueValidation.Enum { - enumValues = append(enumValues, e.Object) - } - } - if fieldType := SchemaDeclType(&prop, prop.XEmbeddedResource); fieldType != nil { - if propName, ok := apiservercel.Escape(name); ok { - fields[propName] = apiservercel.NewDeclField(propName, fieldType, required[name], enumValues, prop.Default.Object) - } - // the min serialized size for an object is 2 (for {}) plus the min size of all its required - // properties - // only include required properties without a default value; default values are filled in - // server-side - if required[name] && prop.Default.Object == nil { - minSerializedSize += int64(len(name)) + fieldType.MinSerializedSize + 4 - } - } - } - objType := apiservercel.NewObjectType("object", fields) - objType.MinSerializedSize = minSerializedSize - return objType - case "string": - if s.ValueValidation != nil { - switch s.ValueValidation.Format { - case "byte": - byteWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("bytes", cel.BytesType, types.Bytes([]byte{}), apiservercel.MinStringSize) - if s.ValueValidation.MaxLength != nil { - byteWithMaxLength.MaxElements = zeroIfNegative(*s.ValueValidation.MaxLength) - } else { - byteWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) - } - return byteWithMaxLength - case "duration": - durationWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("duration", cel.DurationType, types.Duration{Duration: time.Duration(0)}, int64(apiservercel.MinDurationSizeJSON)) - durationWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) - return durationWithMaxLength - case "date": - timestampWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("timestamp", cel.TimestampType, types.Timestamp{Time: time.Time{}}, int64(apiservercel.JSONDateSize)) - timestampWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) - return timestampWithMaxLength - case "date-time": - timestampWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("timestamp", cel.TimestampType, types.Timestamp{Time: time.Time{}}, int64(apiservercel.MinDatetimeSizeJSON)) - timestampWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) - return timestampWithMaxLength - } - } - strWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("string", cel.StringType, types.String(""), apiservercel.MinStringSize) - if s.ValueValidation != nil && s.ValueValidation.MaxLength != nil { - // multiply the user-provided max length by 4 in the case of an otherwise-untyped string - // we do this because the OpenAPIv3 spec indicates that maxLength is specified in runes/code points, - // but we need to reason about length for things like request size, so we use bytes in this code (and an individual - // unicode code point can be up to 4 bytes long) - strWithMaxLength.MaxElements = zeroIfNegative(*s.ValueValidation.MaxLength) * 4 - } else { - strWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) - } - return strWithMaxLength - case "boolean": - return apiservercel.BoolType - case "number": - return apiservercel.DoubleType - case "integer": - return apiservercel.IntType - } - return nil -} - -func zeroIfNegative(v int64) int64 { - if v < 0 { - return 0 - } - return v + return celopenapi.SchemaDeclType(s.ToKubeOpenAPI(), isResourceRoot) } // WithTypeAndObjectMeta ensures the kind, apiVersion and @@ -223,52 +71,3 @@ func WithTypeAndObjectMeta(s *schema.Structural) *schema.Structural { return result } - -// MaxCardinality returns the maximum number of times data conforming to the minimum size given could possibly exist in -// an object serialized to JSON. For cases where a schema is contained under map or array schemas of unbounded -// size, this can be used as an estimate as the worst case number of times data matching the schema could be repeated. -// Note that this only assumes a single comma between data elements, so if the schema is contained under only maps, -// this estimates a higher cardinality that would be possible. DeclType.MinSerializedSize is meant to be passed to -// this function. -func MaxCardinality(minSize int64) uint64 { - sz := minSize + 1 // assume at least one comma between elements - return uint64(maxRequestSizeBytes / sz) -} - -// estimateMaxStringLengthPerRequest estimates the maximum string length (in characters) -// of a string compatible with the format requirements in the provided schema. -// must only be called on schemas of type "string" or x-kubernetes-int-or-string: true -func estimateMaxStringLengthPerRequest(s *schema.Structural) int64 { - if s.ValueValidation == nil || s.XIntOrString { - // subtract 2 to account for "" - return maxRequestSizeBytes - 2 - } - switch s.ValueValidation.Format { - case "duration": - return apiservercel.MaxDurationSizeJSON - case "date": - return apiservercel.JSONDateSize - case "date-time": - return apiservercel.MaxDatetimeSizeJSON - default: - // subtract 2 to account for "" - return maxRequestSizeBytes - 2 - } -} - -// estimateMaxArrayItemsPerRequest estimates the maximum number of array items with -// the provided minimum serialized size that can fit into a single request. -func estimateMaxArrayItemsFromMinSize(minSize int64) int64 { - // subtract 2 to account for [ and ] - return (maxRequestSizeBytes - 2) / (minSize + 1) -} - -// estimateMaxAdditionalPropertiesPerRequest estimates the maximum number of additional properties -// with the provided minimum serialized size that can fit into a single request. -func estimateMaxAdditionalPropertiesFromMinSize(minSize int64) int64 { - // 2 bytes for key + "" + colon + comma + smallest possible value, realistically the actual keys - // will all vary in length - keyValuePairSize := minSize + 6 - // subtract 2 to account for { and } - return (maxRequestSizeBytes - 2) / keyValuePairSize -} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation.go index d3626dc3a60..2a039b6b58e 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/validation.go @@ -366,7 +366,7 @@ func (s *Validator) validateArray(ctx context.Context, fldPath *field.Path, sts correlatableOldItems := makeMapList(sts, oldObj) for i := range obj { var err field.ErrorList - err, remainingBudget = s.Items.Validate(ctx, fldPath.Index(i), sts.Items, obj[i], correlatableOldItems.get(obj[i]), remainingBudget) + err, remainingBudget = s.Items.Validate(ctx, fldPath.Index(i), sts.Items, obj[i], correlatableOldItems.Get(obj[i]), remainingBudget) errs = append(errs, err...) if remainingBudget < 0 { return errs, remainingBudget diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/values.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/values.go index c7e159f5527..8bd4bb8e5b3 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/values.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/values.go @@ -17,688 +17,16 @@ limitations under the License. package cel import ( - "fmt" - "reflect" - "sync" - "time" - - "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" - "github.com/google/cel-go/common/types/traits" + + celopenapi "k8s.io/apiserver/pkg/cel/openapi" structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" - "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model" - "k8s.io/apimachinery/pkg/api/equality" - "k8s.io/apiserver/pkg/cel" - "k8s.io/kube-openapi/pkg/validation/strfmt" ) // UnstructuredToVal converts a Kubernetes unstructured data element to a CEL Val. // The root schema of custom resource schema is expected contain type meta and object meta schemas. // If Embedded resources do not contain type meta and object meta schemas, they will be added automatically. func UnstructuredToVal(unstructured interface{}, schema *structuralschema.Structural) ref.Val { - if unstructured == nil { - if schema.Nullable { - return types.NullValue - } - return types.NewErr("invalid data, got null for schema with nullable=false") - } - if schema.XIntOrString { - switch v := unstructured.(type) { - case string: - return types.String(v) - case int: - return types.Int(v) - case int32: - return types.Int(v) - case int64: - return types.Int(v) - } - return types.NewErr("invalid data, expected XIntOrString value to be either a string or integer") - } - if schema.Type == "object" { - m, ok := unstructured.(map[string]interface{}) - if !ok { - return types.NewErr("invalid data, expected a map for the provided schema with type=object") - } - if schema.XEmbeddedResource || schema.Properties != nil { - if schema.XEmbeddedResource { - schema = model.WithTypeAndObjectMeta(schema) - } - return &unstructuredMap{ - value: m, - schema: schema, - propSchema: func(key string) (*structuralschema.Structural, bool) { - if schema, ok := schema.Properties[key]; ok { - return &schema, true - } - return nil, false - }, - } - } - if schema.AdditionalProperties != nil && schema.AdditionalProperties.Structural != nil { - return &unstructuredMap{ - value: m, - schema: schema, - propSchema: func(key string) (*structuralschema.Structural, bool) { - return schema.AdditionalProperties.Structural, true - }, - } - } - // A object with x-kubernetes-preserve-unknown-fields but no properties or additionalProperties is treated - // as an empty object. - if schema.XPreserveUnknownFields { - return &unstructuredMap{ - value: m, - schema: schema, - propSchema: func(key string) (*structuralschema.Structural, bool) { - return nil, false - }, - } - } - return types.NewErr("invalid object type, expected either Properties or AdditionalProperties with Allows=true and non-empty Schema") - } - - if schema.Type == "array" { - l, ok := unstructured.([]interface{}) - if !ok { - return types.NewErr("invalid data, expected an array for the provided schema with type=array") - } - if schema.Items == nil { - return types.NewErr("invalid array type, expected Items with a non-empty Schema") - } - typedList := unstructuredList{elements: l, itemsSchema: schema.Items} - listType := schema.XListType - if listType != nil { - switch *listType { - case "map": - mapKeys := schema.Extensions.XListMapKeys - return &unstructuredMapList{unstructuredList: typedList, escapedKeyProps: escapeKeyProps(mapKeys)} - case "set": - return &unstructuredSetList{unstructuredList: typedList} - case "atomic": - return &typedList - default: - return types.NewErr("invalid x-kubernetes-list-type, expected 'map', 'set' or 'atomic' but got %s", *listType) - } - } - return &typedList - } - - if schema.Type == "string" { - str, ok := unstructured.(string) - if !ok { - return types.NewErr("invalid data, expected string, got %T", unstructured) - } - if schema.ValueValidation != nil { - switch schema.ValueValidation.Format { - case "duration": - d, err := strfmt.ParseDuration(str) - if err != nil { - return types.NewErr("Invalid duration %s: %v", str, err) - } - return types.Duration{Duration: d} - case "date": - d, err := time.Parse(strfmt.RFC3339FullDate, str) // strfmt uses this format for OpenAPIv3 value validation - if err != nil { - return types.NewErr("Invalid date formatted string %s: %v", str, err) - } - return types.Timestamp{Time: d} - case "date-time": - d, err := strfmt.ParseDateTime(str) - if err != nil { - return types.NewErr("Invalid date-time formatted string %s: %v", str, err) - } - return types.Timestamp{Time: time.Time(d)} - case "byte": - base64 := strfmt.Base64{} - err := base64.UnmarshalText([]byte(str)) - if err != nil { - return types.NewErr("Invalid byte formatted string %s: %v", str, err) - } - return types.Bytes(base64) - } - } - return types.String(str) - } - if schema.Type == "number" { - switch v := unstructured.(type) { - // float representations of whole numbers (e.g. 1.0, 0.0) can convert to int representations (e.g. 1, 0) in yaml - // to json translation, and then get parsed as int64s - case int: - return types.Double(v) - case int32: - return types.Double(v) - case int64: - return types.Double(v) - - case float32: - return types.Double(v) - case float64: - return types.Double(v) - default: - return types.NewErr("invalid data, expected float, got %T", unstructured) - } - } - if schema.Type == "integer" { - switch v := unstructured.(type) { - case int: - return types.Int(v) - case int32: - return types.Int(v) - case int64: - return types.Int(v) - default: - return types.NewErr("invalid data, expected int, got %T", unstructured) - } - } - if schema.Type == "boolean" { - b, ok := unstructured.(bool) - if !ok { - return types.NewErr("invalid data, expected bool, got %T", unstructured) - } - return types.Bool(b) - } - - if schema.XPreserveUnknownFields { - return &unknownPreserved{u: unstructured} - } - - return types.NewErr("invalid type, expected object, array, number, integer, boolean or string, or no type with x-kubernetes-int-or-string or x-kubernetes-preserve-unknown-fields is true, got %s", schema.Type) -} - -// unknownPreserved represents unknown data preserved in custom resources via x-kubernetes-preserve-unknown-fields. -// It preserves the data at runtime without assuming it is of any particular type and supports only equality checking. -// unknownPreserved should be used only for values are not directly accessible in CEL expressions, i.e. for data -// where there is no corresponding CEL type declaration. -type unknownPreserved struct { - u interface{} -} - -func (t *unknownPreserved) ConvertToNative(refType reflect.Type) (interface{}, error) { - return nil, fmt.Errorf("type conversion to '%s' not supported for values preserved by x-kubernetes-preserve-unknown-fields", refType) -} - -func (t *unknownPreserved) ConvertToType(typeValue ref.Type) ref.Val { - return types.NewErr("type conversion to '%s' not supported for values preserved by x-kubernetes-preserve-unknown-fields", typeValue.TypeName()) -} - -func (t *unknownPreserved) Equal(other ref.Val) ref.Val { - return types.Bool(equality.Semantic.DeepEqual(t.u, other.Value())) -} - -func (t *unknownPreserved) Type() ref.Type { - return types.UnknownType -} - -func (t *unknownPreserved) Value() interface{} { - return t.u // used by Equal checks -} - -// unstructuredMapList represents an unstructured data instance of an OpenAPI array with x-kubernetes-list-type=map. -type unstructuredMapList struct { - unstructuredList - escapedKeyProps []string - - sync.Once // for for lazy load of mapOfList since it is only needed if Equals is called - mapOfList map[interface{}]interface{} -} - -func (t *unstructuredMapList) getMap() map[interface{}]interface{} { - t.Do(func() { - t.mapOfList = make(map[interface{}]interface{}, len(t.elements)) - for _, e := range t.elements { - t.mapOfList[t.toMapKey(e)] = e - } - }) - return t.mapOfList -} - -// toMapKey returns a valid golang map key for the given element of the map list. -// element must be a valid map list entry where all map key props are scalar types (which are comparable in go -// and valid for use in a golang map key). -func (t *unstructuredMapList) toMapKey(element interface{}) interface{} { - eObj, ok := element.(map[string]interface{}) - if !ok { - return types.NewErr("unexpected data format for element of array with x-kubernetes-list-type=map: %T", element) - } - // Arrays are comparable in go and may be used as map keys, but maps and slices are not. - // So we can special case small numbers of key props as arrays and fall back to serialization - // for larger numbers of key props - if len(t.escapedKeyProps) == 1 { - return eObj[t.escapedKeyProps[0]] - } - if len(t.escapedKeyProps) == 2 { - return [2]interface{}{eObj[t.escapedKeyProps[0]], eObj[t.escapedKeyProps[1]]} - } - if len(t.escapedKeyProps) == 3 { - return [3]interface{}{eObj[t.escapedKeyProps[0]], eObj[t.escapedKeyProps[1]], eObj[t.escapedKeyProps[2]]} - } - - key := make([]interface{}, len(t.escapedKeyProps)) - for i, kf := range t.escapedKeyProps { - key[i] = eObj[kf] - } - return fmt.Sprintf("%v", key) -} - -// Equal on a map list ignores list element order. -func (t *unstructuredMapList) Equal(other ref.Val) ref.Val { - oMapList, ok := other.(traits.Lister) - if !ok { - return types.MaybeNoSuchOverloadErr(other) - } - sz := types.Int(len(t.elements)) - if sz != oMapList.Size() { - return types.False - } - tMap := t.getMap() - for it := oMapList.Iterator(); it.HasNext() == types.True; { - v := it.Next() - k := t.toMapKey(v.Value()) - tVal, ok := tMap[k] - if !ok { - return types.False - } - eq := UnstructuredToVal(tVal, t.itemsSchema).Equal(v) - if eq != types.True { - return eq // either false or error - } - } - return types.True -} - -// Add for a map list `X + Y` performs a merge where the array positions of all keys in `X` are preserved but the values -// are overwritten by values in `Y` when the key sets of `X` and `Y` intersect. Elements in `Y` with -// non-intersecting keys are appended, retaining their partial order. -func (t *unstructuredMapList) Add(other ref.Val) ref.Val { - oMapList, ok := other.(traits.Lister) - if !ok { - return types.MaybeNoSuchOverloadErr(other) - } - elements := make([]interface{}, len(t.elements)) - keyToIdx := map[interface{}]int{} - for i, e := range t.elements { - k := t.toMapKey(e) - keyToIdx[k] = i - elements[i] = e - } - for it := oMapList.Iterator(); it.HasNext() == types.True; { - v := it.Next().Value() - k := t.toMapKey(v) - if overwritePosition, ok := keyToIdx[k]; ok { - elements[overwritePosition] = v - } else { - elements = append(elements, v) - } - } - return &unstructuredMapList{ - unstructuredList: unstructuredList{elements: elements, itemsSchema: t.itemsSchema}, - escapedKeyProps: t.escapedKeyProps, - } -} - -// escapeKeyProps returns identifiers with Escape applied to each. -// Identifiers that cannot be escaped are left as-is. They are inaccessible to CEL programs but are -// are still needed internally to perform equality checks. -func escapeKeyProps(idents []string) []string { - result := make([]string, len(idents)) - for i, prop := range idents { - if escaped, ok := cel.Escape(prop); ok { - result[i] = escaped - } else { - result[i] = prop - } - } - return result -} - -// unstructuredSetList represents an unstructured data instance of an OpenAPI array with x-kubernetes-list-type=set. -type unstructuredSetList struct { - unstructuredList - escapedKeyProps []string - - sync.Once // for for lazy load of setOfList since it is only needed if Equals is called - set map[interface{}]struct{} -} - -func (t *unstructuredSetList) getSet() map[interface{}]struct{} { - // sets are only allowed to contain scalar elements, which are comparable in go, and can safely be used as - // golang map keys - t.Do(func() { - t.set = make(map[interface{}]struct{}, len(t.elements)) - for _, e := range t.elements { - t.set[e] = struct{}{} - } - }) - return t.set -} - -// Equal on a map list ignores list element order. -func (t *unstructuredSetList) Equal(other ref.Val) ref.Val { - oSetList, ok := other.(traits.Lister) - if !ok { - return types.MaybeNoSuchOverloadErr(other) - } - sz := types.Int(len(t.elements)) - if sz != oSetList.Size() { - return types.False - } - tSet := t.getSet() - for it := oSetList.Iterator(); it.HasNext() == types.True; { - next := it.Next().Value() - _, ok := tSet[next] - if !ok { - return types.False - } - } - return types.True -} - -// Add for a set list `X + Y` performs a union where the array positions of all elements in `X` are preserved and -// non-intersecting elements in `Y` are appended, retaining their partial order. -func (t *unstructuredSetList) Add(other ref.Val) ref.Val { - oSetList, ok := other.(traits.Lister) - if !ok { - return types.MaybeNoSuchOverloadErr(other) - } - elements := t.elements - set := t.getSet() - for it := oSetList.Iterator(); it.HasNext() == types.True; { - next := it.Next().Value() - if _, ok := set[next]; !ok { - set[next] = struct{}{} - elements = append(elements, next) - } - } - return &unstructuredSetList{ - unstructuredList: unstructuredList{elements: elements, itemsSchema: t.itemsSchema}, - escapedKeyProps: t.escapedKeyProps, - } -} - -// unstructuredList represents an unstructured data instance of an OpenAPI array with x-kubernetes-list-type=atomic (the default). -type unstructuredList struct { - elements []interface{} - itemsSchema *structuralschema.Structural -} - -var _ = traits.Lister(&unstructuredList{}) - -func (t *unstructuredList) ConvertToNative(typeDesc reflect.Type) (interface{}, error) { - switch typeDesc.Kind() { - case reflect.Slice: - return t.elements, nil - } - return nil, fmt.Errorf("type conversion error from '%s' to '%s'", t.Type(), typeDesc) -} - -func (t *unstructuredList) ConvertToType(typeValue ref.Type) ref.Val { - switch typeValue { - case types.ListType: - return t - case types.TypeType: - return types.ListType - } - return types.NewErr("type conversion error from '%s' to '%s'", t.Type(), typeValue.TypeName()) -} - -func (t *unstructuredList) Equal(other ref.Val) ref.Val { - oList, ok := other.(traits.Lister) - if !ok { - return types.MaybeNoSuchOverloadErr(other) - } - sz := types.Int(len(t.elements)) - if sz != oList.Size() { - return types.False - } - for i := types.Int(0); i < sz; i++ { - eq := t.Get(i).Equal(oList.Get(i)) - if eq != types.True { - return eq // either false or error - } - } - return types.True -} - -func (t *unstructuredList) Type() ref.Type { - return types.ListType -} - -func (t *unstructuredList) Value() interface{} { - return t.elements -} - -func (t *unstructuredList) Add(other ref.Val) ref.Val { - oList, ok := other.(traits.Lister) - if !ok { - return types.MaybeNoSuchOverloadErr(other) - } - elements := t.elements - for it := oList.Iterator(); it.HasNext() == types.True; { - next := it.Next().Value() - elements = append(elements, next) - } - - return &unstructuredList{elements: elements, itemsSchema: t.itemsSchema} -} - -func (t *unstructuredList) Contains(val ref.Val) ref.Val { - if types.IsUnknownOrError(val) { - return val - } - var err ref.Val - sz := len(t.elements) - for i := 0; i < sz; i++ { - elem := UnstructuredToVal(t.elements[i], t.itemsSchema) - cmp := elem.Equal(val) - b, ok := cmp.(types.Bool) - if !ok && err == nil { - err = types.MaybeNoSuchOverloadErr(cmp) - } - if b == types.True { - return types.True - } - } - if err != nil { - return err - } - return types.False -} - -func (t *unstructuredList) Get(idx ref.Val) ref.Val { - iv, isInt := idx.(types.Int) - if !isInt { - return types.ValOrErr(idx, "unsupported index: %v", idx) - } - i := int(iv) - if i < 0 || i >= len(t.elements) { - return types.NewErr("index out of bounds: %v", idx) - } - return UnstructuredToVal(t.elements[i], t.itemsSchema) -} - -func (t *unstructuredList) Iterator() traits.Iterator { - items := make([]ref.Val, len(t.elements)) - for i, item := range t.elements { - itemCopy := item - items[i] = UnstructuredToVal(itemCopy, t.itemsSchema) - } - return &listIterator{unstructuredList: t, items: items} -} - -type listIterator struct { - *unstructuredList - items []ref.Val - idx int -} - -func (it *listIterator) HasNext() ref.Val { - return types.Bool(it.idx < len(it.items)) -} - -func (it *listIterator) Next() ref.Val { - item := it.items[it.idx] - it.idx++ - return item -} - -func (t *unstructuredList) Size() ref.Val { - return types.Int(len(t.elements)) -} - -// unstructuredMap represented an unstructured data instance of an OpenAPI object. -type unstructuredMap struct { - value map[string]interface{} - schema *structuralschema.Structural - // propSchema finds the schema to use for a particular map key. - propSchema func(key string) (*structuralschema.Structural, bool) -} - -var _ = traits.Mapper(&unstructuredMap{}) - -func (t *unstructuredMap) ConvertToNative(typeDesc reflect.Type) (interface{}, error) { - switch typeDesc.Kind() { - case reflect.Map: - return t.value, nil - } - return nil, fmt.Errorf("type conversion error from '%s' to '%s'", t.Type(), typeDesc) -} - -func (t *unstructuredMap) ConvertToType(typeValue ref.Type) ref.Val { - switch typeValue { - case types.MapType: - return t - case types.TypeType: - return types.MapType - } - return types.NewErr("type conversion error from '%s' to '%s'", t.Type(), typeValue.TypeName()) -} - -func (t *unstructuredMap) Equal(other ref.Val) ref.Val { - oMap, isMap := other.(traits.Mapper) - if !isMap { - return types.MaybeNoSuchOverloadErr(other) - } - if t.Size() != oMap.Size() { - return types.False - } - for key, value := range t.value { - if propSchema, ok := t.propSchema(key); ok { - ov, found := oMap.Find(types.String(key)) - if !found { - return types.False - } - v := UnstructuredToVal(value, propSchema) - vEq := v.Equal(ov) - if vEq != types.True { - return vEq // either false or error - } - } else { - // Must be an object with properties. - // Since we've encountered an unknown field, fallback to unstructured equality checking. - ouMap, ok := other.(*unstructuredMap) - if !ok { - // The compiler ensures equality is against the same type of object, so this should be unreachable - return types.MaybeNoSuchOverloadErr(other) - } - if oValue, ok := ouMap.value[key]; ok { - if !equality.Semantic.DeepEqual(value, oValue) { - return types.False - } - } - } - } - return types.True -} - -func (t *unstructuredMap) Type() ref.Type { - return types.MapType -} - -func (t *unstructuredMap) Value() interface{} { - return t.value -} - -func (t *unstructuredMap) Contains(key ref.Val) ref.Val { - v, found := t.Find(key) - if v != nil && types.IsUnknownOrError(v) { - return v - } - - return types.Bool(found) -} - -func (t *unstructuredMap) Get(key ref.Val) ref.Val { - v, found := t.Find(key) - if found { - return v - } - return types.ValOrErr(key, "no such key: %v", key) -} - -func (t *unstructuredMap) Iterator() traits.Iterator { - isObject := t.schema.Properties != nil - keys := make([]ref.Val, len(t.value)) - i := 0 - for k := range t.value { - if _, ok := t.propSchema(k); ok { - mapKey := k - if isObject { - if escaped, ok := cel.Escape(k); ok { - mapKey = escaped - } - } - keys[i] = types.String(mapKey) - i++ - } - } - return &mapIterator{unstructuredMap: t, keys: keys} -} - -type mapIterator struct { - *unstructuredMap - keys []ref.Val - idx int -} - -func (it *mapIterator) HasNext() ref.Val { - return types.Bool(it.idx < len(it.keys)) -} - -func (it *mapIterator) Next() ref.Val { - key := it.keys[it.idx] - it.idx++ - return key -} - -func (t *unstructuredMap) Size() ref.Val { - return types.Int(len(t.value)) -} - -func (t *unstructuredMap) Find(key ref.Val) (ref.Val, bool) { - isObject := t.schema.Properties != nil - keyStr, ok := key.(types.String) - if !ok { - return types.MaybeNoSuchOverloadErr(key), true - } - k := keyStr.Value().(string) - if isObject { - k, ok = cel.Unescape(k) - if !ok { - return nil, false - } - } - if v, ok := t.value[k]; ok { - // If this is an object with properties, not an object with additionalProperties, - // then null valued nullable fields are treated the same as absent optional fields. - if isObject && v == nil { - return nil, false - } - if propSchema, ok := t.propSchema(k); ok { - return UnstructuredToVal(v, propSchema), true - } - } - - return nil, false + return celopenapi.UnstructuredToVal(unstructured, schema.ToKubeOpenAPI()) } diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go index d018fc8a10d..ff67c9eed92 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go @@ -23,12 +23,12 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" ) -// mapList provides a "lookup by key" operation for lists (arrays) with x-kubernetes-list-type=map. -type mapList interface { - // get returns the first element having given key, for all - // x-kubernetes-list-map-keys, to the provided object. If the provided object isn't itself a valid mapList element, +// MapList provides a "lookup by key" operation for lists (arrays) with x-kubernetes-list-type=map. +type MapList interface { + // Get returns the first element having given key, for all + // x-kubernetes-list-map-keys, to the provided object. If the provided object isn't itself a valid MapList element, // get returns nil. - get(interface{}) interface{} + Get(interface{}) interface{} } type keyStrategy interface { @@ -91,10 +91,10 @@ func (ks *multiKeyStrategy) CompositeKeyFor(obj map[string]interface{}) (interfa return delimited.String(), true } -// emptyMapList is a mapList containing no elements. +// emptyMapList is a MapList containing no elements. type emptyMapList struct{} -func (emptyMapList) get(interface{}) interface{} { +func (emptyMapList) Get(interface{}) interface{} { return nil } @@ -107,7 +107,7 @@ type mapListImpl struct { unkeyedItems []interface{} } -func (a *mapListImpl) get(obj interface{}) interface{} { +func (a *mapListImpl) Get(obj interface{}) interface{} { mobj, ok := obj.(map[string]interface{}) if !ok { return nil @@ -162,10 +162,10 @@ func makeKeyStrategy(sts *spec.Schema) keyStrategy { } } -// makeMapList returns a queryable interface over the provided x-kubernetes-list-type=map +// MakeMapList returns a queryable interface over the provided x-kubernetes-list-type=map // keyedItems. If the provided schema is _not_ an array with x-kubernetes-list-type=map, returns an // empty mapList. -func makeMapList(sts *spec.Schema, items []interface{}) (rv mapList) { +func MakeMapList(sts *spec.Schema, items []interface{}) (rv MapList) { if !sts.Type.Contains("array") || getXListType(sts) != "map" || len(getXListMapKeys(sts)) == 0 { return emptyMapList{} } diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist_test.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist_test.go index 134a0f2cdbb..62d303d1505 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist_test.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist_test.go @@ -296,11 +296,11 @@ func TestMapList(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - mapList := makeMapList(tc.sts, tc.items) + mapList := MakeMapList(tc.sts, tc.items) for _, warmUp := range tc.warmUpQueries { - mapList.get(warmUp) + mapList.Get(warmUp) } - actual := mapList.get(tc.query) + actual := mapList.Get(tc.query) if !reflect.DeepEqual(tc.expected, actual) { t.Errorf("got: %v, expected %v", actual, tc.expected) } From 7eb7c8b4fadc40f987de668c37abc99c1d58b557 Mon Sep 17 00:00:00 2001 From: Jiahui Feng Date: Mon, 6 Feb 2023 18:56:51 -0800 Subject: [PATCH 6/8] adaptor between structral and OpenAPI. --- .../pkg/apiserver/schema/cel/maplist.go | 7 +- .../pkg/apiserver/schema/cel/model/adaptor.go | 152 ++++++++++++++++++ .../pkg/apiserver/schema/cel/model/schemas.go | 4 +- .../pkg/apiserver/schema/cel/values.go | 6 +- .../apiserver/pkg/cel/common/adaptor.go | 81 ++++++++++ .../pkg/cel/{openapi => common}/maplist.go | 18 +-- .../pkg/cel/{openapi => common}/schemas.go | 65 ++++---- .../pkg/cel/{openapi => common}/values.go | 67 ++++---- .../apiserver/pkg/cel/openapi/adaptor.go | 147 +++++++++++++++++ .../apiserver/pkg/cel/openapi/extensions.go | 27 +--- 10 files changed, 464 insertions(+), 110 deletions(-) create mode 100644 staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/adaptor.go create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/common/adaptor.go rename staging/src/k8s.io/apiserver/pkg/cel/{openapi => common}/maplist.go (91%) rename staging/src/k8s.io/apiserver/pkg/cel/{openapi => common}/schemas.go (86%) rename staging/src/k8s.io/apiserver/pkg/cel/{openapi => common}/values.go (93%) create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/adaptor.go diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist.go index 99d9f505914..32cdf86fef4 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/maplist.go @@ -18,12 +18,13 @@ package cel import ( "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" - celopenapi "k8s.io/apiserver/pkg/cel/openapi" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model" + "k8s.io/apiserver/pkg/cel/common" ) // makeMapList returns a queryable interface over the provided x-kubernetes-list-type=map // keyedItems. If the provided schema is _not_ an array with x-kubernetes-list-type=map, returns an // empty mapList. -func makeMapList(sts *schema.Structural, items []interface{}) (rv celopenapi.MapList) { - return celopenapi.MakeMapList(sts.ToKubeOpenAPI(), items) +func makeMapList(sts *schema.Structural, items []interface{}) (rv common.MapList) { + return common.MakeMapList(&model.Structural{Structural: sts}, items) } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/adaptor.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/adaptor.go new file mode 100644 index 00000000000..e3e940afa0b --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/adaptor.go @@ -0,0 +1,152 @@ +/* +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 model + +import ( + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiserver/pkg/cel/common" +) + +var _ common.Schema = (*Structural)(nil) +var _ common.SchemaOrBool = (*StructuralOrBool)(nil) + +type Structural struct { + Structural *schema.Structural +} + +type StructuralOrBool struct { + StructuralOrBool *schema.StructuralOrBool +} + +func (sb *StructuralOrBool) Schema() common.Schema { + if sb.StructuralOrBool.Structural == nil { + return nil + } + return &Structural{Structural: sb.StructuralOrBool.Structural} +} + +func (sb *StructuralOrBool) Allows() bool { + return sb.StructuralOrBool.Bool +} + +func (s *Structural) Type() string { + return s.Structural.Type +} + +func (s *Structural) Format() string { + if s.Structural.ValueValidation == nil { + return "" + } + return s.Structural.ValueValidation.Format +} + +func (s *Structural) Items() common.Schema { + return &Structural{Structural: s.Structural.Items} +} + +func (s *Structural) Properties() map[string]common.Schema { + if s.Structural.Properties == nil { + return nil + } + res := make(map[string]common.Schema, len(s.Structural.Properties)) + for n, prop := range s.Structural.Properties { + s := prop + res[n] = &Structural{Structural: &s} + } + return res +} + +func (s *Structural) AdditionalProperties() common.SchemaOrBool { + if s.Structural.AdditionalProperties == nil { + return nil + } + return &StructuralOrBool{StructuralOrBool: s.Structural.AdditionalProperties} +} + +func (s *Structural) Default() any { + return s.Structural.Default.Object +} + +func (s *Structural) MaxItems() *int64 { + if s.Structural.ValueValidation == nil { + return nil + } + return s.Structural.ValueValidation.MaxItems +} + +func (s *Structural) MaxLength() *int64 { + if s.Structural.ValueValidation == nil { + return nil + } + return s.Structural.ValueValidation.MaxLength +} + +func (s *Structural) MaxProperties() *int64 { + if s.Structural.ValueValidation == nil { + return nil + } + return s.Structural.ValueValidation.MaxProperties +} + +func (s *Structural) Required() []string { + if s.Structural.ValueValidation == nil { + return nil + } + return s.Structural.ValueValidation.Required +} + +func (s *Structural) Enum() []any { + if s.Structural.ValueValidation == nil { + return nil + } + ret := make([]any, 0, len(s.Structural.ValueValidation.Enum)) + for _, e := range s.Structural.ValueValidation.Enum { + ret = append(ret, e.Object) + } + return ret +} + +func (s *Structural) Nullable() bool { + return s.Structural.Nullable +} + +func (s *Structural) IsXIntOrString() bool { + return s.Structural.XIntOrString +} + +func (s *Structural) IsXEmbeddedResource() bool { + return s.Structural.XEmbeddedResource +} + +func (s *Structural) IsXPreserveUnknownFields() bool { + return s.Structural.XPreserveUnknownFields +} + +func (s *Structural) XListType() string { + if s.Structural.XListType == nil { + return "" + } + return *s.Structural.XListType +} + +func (s *Structural) XListMapKeys() []string { + return s.Structural.XListMapKeys +} + +func (s *Structural) WithTypeAndObjectMeta() common.Schema { + return &Structural{Structural: WithTypeAndObjectMeta(s.Structural)} +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/schemas.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/schemas.go index 1bd8713026f..6b49e67a404 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/schemas.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model/schemas.go @@ -18,7 +18,7 @@ package model import ( apiservercel "k8s.io/apiserver/pkg/cel" - celopenapi "k8s.io/apiserver/pkg/cel/openapi" + "k8s.io/apiserver/pkg/cel/common" "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" ) @@ -33,7 +33,7 @@ import ( // // The CEL declaration for objects with XPreserveUnknownFields does not expose unknown fields. func SchemaDeclType(s *schema.Structural, isResourceRoot bool) *apiservercel.DeclType { - return celopenapi.SchemaDeclType(s.ToKubeOpenAPI(), isResourceRoot) + return common.SchemaDeclType(&Structural{Structural: s}, isResourceRoot) } // WithTypeAndObjectMeta ensures the kind, apiVersion and diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/values.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/values.go index 8bd4bb8e5b3..8b879eaf206 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/values.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/values.go @@ -19,14 +19,14 @@ package cel import ( "github.com/google/cel-go/common/types/ref" - celopenapi "k8s.io/apiserver/pkg/cel/openapi" - structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model" + celopenapi "k8s.io/apiserver/pkg/cel/common" ) // UnstructuredToVal converts a Kubernetes unstructured data element to a CEL Val. // The root schema of custom resource schema is expected contain type meta and object meta schemas. // If Embedded resources do not contain type meta and object meta schemas, they will be added automatically. func UnstructuredToVal(unstructured interface{}, schema *structuralschema.Structural) ref.Val { - return celopenapi.UnstructuredToVal(unstructured, schema.ToKubeOpenAPI()) + return celopenapi.UnstructuredToVal(unstructured, &model.Structural{Structural: schema}) } diff --git a/staging/src/k8s.io/apiserver/pkg/cel/common/adaptor.go b/staging/src/k8s.io/apiserver/pkg/cel/common/adaptor.go new file mode 100644 index 00000000000..c28d6ce510a --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/common/adaptor.go @@ -0,0 +1,81 @@ +/* +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 common + +// Schema is the adapted type for an OpenAPI schema that CEL uses. +// This schema does not cover all OpenAPI fields but only these CEL requires +// are exposed as getters. +type Schema interface { + // Type returns the OpenAPI type. + // Multiple types are not supported. It should return + // empty string if no type is specified. + Type() string + + // Format returns the OpenAPI format. May be empty + Format() string + + // Items returns the OpenAPI items. or nil of this field does not exist or + // contains no schema. + Items() Schema + + // Properties returns the OpenAPI properties, or nil if this field does not + // exist. + // The values of the returned map are of the adapted type. + Properties() map[string]Schema + + // AdditionalProperties returns the OpenAPI additional properties field, + // or nil if this field does not exist. + AdditionalProperties() SchemaOrBool + + // Default returns the OpenAPI default field, or nil if this field does not exist. + Default() any + + Validations + KubeExtensions + + // WithTypeAndObjectMeta returns a schema that has the type and object meta set. + // the type includes "kind", "apiVersion" field + // the "metadata" field requires "name" and "generateName" to be set + // The original schema must not be mutated. Make a copy if necessary. + WithTypeAndObjectMeta() Schema +} + +// Validations contains OpenAPI validation that the CEL library uses. +type Validations interface { + MaxItems() *int64 + MaxLength() *int64 + MaxProperties() *int64 + Required() []string + Enum() []any + Nullable() bool +} + +// KubeExtensions contains Kubernetes-specific extensions to the OpenAPI schema. +type KubeExtensions interface { + IsXIntOrString() bool + IsXEmbeddedResource() bool + IsXPreserveUnknownFields() bool + XListType() string + XListMapKeys() []string +} + +// SchemaOrBool contains either a schema or a boolean indicating if the object +// can contain any fields. +type SchemaOrBool interface { + Schema() Schema + Allows() bool +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go b/staging/src/k8s.io/apiserver/pkg/cel/common/maplist.go similarity index 91% rename from staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go rename to staging/src/k8s.io/apiserver/pkg/cel/common/maplist.go index ff67c9eed92..99fda092e4b 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/openapi/maplist.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/common/maplist.go @@ -14,13 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -package openapi +package common import ( "fmt" "strings" - - "k8s.io/kube-openapi/pkg/validation/spec" ) // MapList provides a "lookup by key" operation for lists (arrays) with x-kubernetes-list-type=map. @@ -60,7 +58,7 @@ func (ks *singleKeyStrategy) CompositeKeyFor(obj map[string]interface{}) (interf // multiKeyStrategy computes a composite key of all key values. type multiKeyStrategy struct { - sts *spec.Schema + sts Schema } // CompositeKeyFor returns a composite key computed from the values of all @@ -69,7 +67,7 @@ func (ks *multiKeyStrategy) CompositeKeyFor(obj map[string]interface{}) (interfa const keyDelimiter = "\x00" // 0 byte should never appear in the composite key except as delimiter var delimited strings.Builder - for _, key := range getXListMapKeys(ks.sts) { + for _, key := range ks.sts.XListMapKeys() { v, ok := obj[key] if !ok { return nil, false @@ -99,7 +97,7 @@ func (emptyMapList) Get(interface{}) interface{} { } type mapListImpl struct { - sts *spec.Schema + sts Schema ks keyStrategy // keyedItems contains all lazily keyed map items keyedItems map[interface{}]interface{} @@ -148,8 +146,8 @@ func (a *mapListImpl) Get(obj interface{}) interface{} { return nil } -func makeKeyStrategy(sts *spec.Schema) keyStrategy { - listMapKeys := getXListMapKeys(sts) +func makeKeyStrategy(sts Schema) keyStrategy { + listMapKeys := sts.XListMapKeys() if len(listMapKeys) == 1 { key := listMapKeys[0] return &singleKeyStrategy{ @@ -165,8 +163,8 @@ func makeKeyStrategy(sts *spec.Schema) keyStrategy { // MakeMapList returns a queryable interface over the provided x-kubernetes-list-type=map // keyedItems. If the provided schema is _not_ an array with x-kubernetes-list-type=map, returns an // empty mapList. -func MakeMapList(sts *spec.Schema, items []interface{}) (rv MapList) { - if !sts.Type.Contains("array") || getXListType(sts) != "map" || len(getXListMapKeys(sts)) == 0 { +func MakeMapList(sts Schema, items []interface{}) (rv MapList) { + if sts.Type() != "array" || sts.XListType() != "map" || len(sts.XListMapKeys()) == 0 || len(items) == 0 { return emptyMapList{} } ks := makeKeyStrategy(sts) diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas.go b/staging/src/k8s.io/apiserver/pkg/cel/common/schemas.go similarity index 86% rename from staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas.go rename to staging/src/k8s.io/apiserver/pkg/cel/common/schemas.go index 28a3663af23..3fdd3a6c8ba 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/openapi/schemas.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/common/schemas.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package openapi +package common import ( "time" @@ -37,11 +37,11 @@ const maxRequestSizeBytes = apiservercel.DefaultMaxRequestSizeBytes // if their schema is not exposed. // // The CEL declaration for objects with XPreserveUnknownFields does not expose unknown fields. -func SchemaDeclType(s *spec.Schema, isResourceRoot bool) *apiservercel.DeclType { +func SchemaDeclType(s Schema, isResourceRoot bool) *apiservercel.DeclType { if s == nil { return nil } - if isXIntOrString(s) { + if s.IsXIntOrString() { // schemas using XIntOrString are not required to have a type. // intOrStringType represents the x-kubernetes-int-or-string union type in CEL expressions. @@ -67,24 +67,19 @@ func SchemaDeclType(s *spec.Schema, isResourceRoot bool) *apiservercel.DeclType // 'apiVersion', 'kind', 'metadata.name' and 'metadata.generateName' are always accessible to validator rules // at the root of resources, even if not specified in the schema. // This includes the root of a custom resource and the root of XEmbeddedResource objects. - s = WithTypeAndObjectMeta(s) + s = s.WithTypeAndObjectMeta() } - // If the schema is not an "int-or-string", type must present. - if len(s.Type) == 0 { - return nil - } - - switch s.Type[0] { + switch s.Type() { case "array": - if s.Items != nil { - itemsType := SchemaDeclType(s.Items.Schema, isXEmbeddedResource(s.Items.Schema)) + if s.Items() != nil { + itemsType := SchemaDeclType(s.Items(), s.Items().IsXEmbeddedResource()) if itemsType == nil { return nil } var maxItems int64 - if s.MaxItems != nil { - maxItems = zeroIfNegative(*s.MaxItems) + if s.MaxItems() != nil { + maxItems = zeroIfNegative(*s.MaxItems()) } else { maxItems = estimateMaxArrayItemsFromMinSize(itemsType.MinSerializedSize) } @@ -92,12 +87,12 @@ func SchemaDeclType(s *spec.Schema, isResourceRoot bool) *apiservercel.DeclType } return nil case "object": - if s.AdditionalProperties != nil && s.AdditionalProperties.Schema != nil { - propsType := SchemaDeclType(s.AdditionalProperties.Schema, isXEmbeddedResource(s.AdditionalProperties.Schema)) + if s.AdditionalProperties() != nil && s.AdditionalProperties().Schema() != nil { + propsType := SchemaDeclType(s.AdditionalProperties().Schema(), s.AdditionalProperties().Schema().IsXEmbeddedResource()) if propsType != nil { var maxProperties int64 - if s.MaxProperties != nil { - maxProperties = zeroIfNegative(*s.MaxProperties) + if s.MaxProperties() != nil { + maxProperties = zeroIfNegative(*s.MaxProperties()) } else { maxProperties = estimateMaxAdditionalPropertiesFromMinSize(propsType.MinSerializedSize) } @@ -105,32 +100,32 @@ func SchemaDeclType(s *spec.Schema, isResourceRoot bool) *apiservercel.DeclType } return nil } - fields := make(map[string]*apiservercel.DeclField, len(s.Properties)) + fields := make(map[string]*apiservercel.DeclField, len(s.Properties())) required := map[string]bool{} - if s.Required != nil { - for _, f := range s.Required { + if s.Required() != nil { + for _, f := range s.Required() { required[f] = true } } // an object will always be serialized at least as {}, so account for that minSerializedSize := int64(2) - for name, prop := range s.Properties { + for name, prop := range s.Properties() { var enumValues []interface{} - if prop.Enum != nil { - for _, e := range prop.Enum { + if prop.Enum() != nil { + for _, e := range prop.Enum() { enumValues = append(enumValues, e) } } - if fieldType := SchemaDeclType(&prop, isXEmbeddedResource(&prop)); fieldType != nil { + if fieldType := SchemaDeclType(prop, prop.IsXEmbeddedResource()); fieldType != nil { if propName, ok := apiservercel.Escape(name); ok { - fields[propName] = apiservercel.NewDeclField(propName, fieldType, required[name], enumValues, prop.Default) + fields[propName] = apiservercel.NewDeclField(propName, fieldType, required[name], enumValues, prop.Default()) } // the min serialized size for an object is 2 (for {}) plus the min size of all its required // properties // only include required properties without a default value; default values are filled in // server-side - if required[name] && prop.Default == nil { + if required[name] && prop.Default() == nil { minSerializedSize += int64(len(name)) + fieldType.MinSerializedSize + 4 } } @@ -139,11 +134,11 @@ func SchemaDeclType(s *spec.Schema, isResourceRoot bool) *apiservercel.DeclType objType.MinSerializedSize = minSerializedSize return objType case "string": - switch s.Format { + switch s.Format() { case "byte": byteWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("bytes", cel.BytesType, types.Bytes([]byte{}), apiservercel.MinStringSize) - if s.MaxLength != nil { - byteWithMaxLength.MaxElements = zeroIfNegative(*s.MaxLength) + if s.MaxLength() != nil { + byteWithMaxLength.MaxElements = zeroIfNegative(*s.MaxLength()) } else { byteWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) } @@ -163,12 +158,12 @@ func SchemaDeclType(s *spec.Schema, isResourceRoot bool) *apiservercel.DeclType } strWithMaxLength := apiservercel.NewSimpleTypeWithMinSize("string", cel.StringType, types.String(""), apiservercel.MinStringSize) - if s.MaxLength != nil { + if s.MaxLength() != nil { // multiply the user-provided max length by 4 in the case of an otherwise-untyped string // we do this because the OpenAPIv3 spec indicates that maxLength is specified in runes/code points, // but we need to reason about length for things like request size, so we use bytes in this code (and an individual // unicode code point can be up to 4 bytes long) - strWithMaxLength.MaxElements = zeroIfNegative(*s.MaxLength) * 4 + strWithMaxLength.MaxElements = zeroIfNegative(*s.MaxLength()) * 4 } else { strWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s) } @@ -227,11 +222,11 @@ func WithTypeAndObjectMeta(s *spec.Schema) *spec.Schema { // estimateMaxStringLengthPerRequest estimates the maximum string length (in characters) // of a string compatible with the format requirements in the provided schema. // must only be called on schemas of type "string" or x-kubernetes-int-or-string: true -func estimateMaxStringLengthPerRequest(s *spec.Schema) int64 { - if isXIntOrString(s) { +func estimateMaxStringLengthPerRequest(s Schema) int64 { + if s.IsXIntOrString() { return maxRequestSizeBytes - 2 } - switch s.Format { + switch s.Format() { case "duration": return apiservercel.MaxDurationSizeJSON case "date": diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/values.go b/staging/src/k8s.io/apiserver/pkg/cel/common/values.go similarity index 93% rename from staging/src/k8s.io/apiserver/pkg/cel/openapi/values.go rename to staging/src/k8s.io/apiserver/pkg/cel/common/values.go index b435f98c64e..e6d7b99757e 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/openapi/values.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/common/values.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package openapi +package common import ( "fmt" @@ -28,21 +28,20 @@ import ( "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apiserver/pkg/cel" - "k8s.io/kube-openapi/pkg/validation/spec" "k8s.io/kube-openapi/pkg/validation/strfmt" ) // UnstructuredToVal converts a Kubernetes unstructured data element to a CEL Val. // The root schema of custom resource schema is expected contain type meta and object meta schemas. // If Embedded resources do not contain type meta and object meta schemas, they will be added automatically. -func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { +func UnstructuredToVal(unstructured interface{}, schema Schema) ref.Val { if unstructured == nil { - if schema.Nullable { + if schema.Nullable() { return types.NullValue } return types.NewErr("invalid data, got null for schema with nullable=false") } - if isXIntOrString(schema) { + if schema.IsXIntOrString() { switch v := unstructured.(type) { case string: return types.String(v) @@ -55,42 +54,42 @@ func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { } return types.NewErr("invalid data, expected XIntOrString value to be either a string or integer") } - if schema.Type.Contains("object") { + if schema.Type() == "object" { m, ok := unstructured.(map[string]interface{}) if !ok { return types.NewErr("invalid data, expected a map for the provided schema with type=object") } - if isXEmbeddedResource(schema) || schema.Properties != nil { - if isXEmbeddedResource(schema) { - schema = WithTypeAndObjectMeta(schema) + if schema.IsXEmbeddedResource() || schema.Properties() != nil { + if schema.IsXEmbeddedResource() { + schema = schema.WithTypeAndObjectMeta() } return &unstructuredMap{ value: m, schema: schema, - propSchema: func(key string) (*spec.Schema, bool) { - if schema, ok := schema.Properties[key]; ok { - return &schema, true + propSchema: func(key string) (Schema, bool) { + if schema, ok := schema.Properties()[key]; ok { + return schema, true } return nil, false }, } } - if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { + if schema.AdditionalProperties() != nil && schema.AdditionalProperties().Schema() != nil { return &unstructuredMap{ value: m, schema: schema, - propSchema: func(key string) (*spec.Schema, bool) { - return schema.AdditionalProperties.Schema, true + propSchema: func(key string) (Schema, bool) { + return schema.AdditionalProperties().Schema(), true }, } } // A object with x-kubernetes-preserve-unknown-fields but no properties or additionalProperties is treated // as an empty object. - if isXPreserveUnknownFields(schema) { + if schema.IsXPreserveUnknownFields() { return &unstructuredMap{ value: m, schema: schema, - propSchema: func(key string) (*spec.Schema, bool) { + propSchema: func(key string) (Schema, bool) { return nil, false }, } @@ -98,20 +97,20 @@ func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { return types.NewErr("invalid object type, expected either Properties or AdditionalProperties with Allows=true and non-empty Schema") } - if schema.Type.Contains("array") { + if schema.Type() == "array" { l, ok := unstructured.([]interface{}) if !ok { return types.NewErr("invalid data, expected an array for the provided schema with type=array") } - if schema.Items == nil { + if schema.Items() == nil { return types.NewErr("invalid array type, expected Items with a non-empty Schema") } - typedList := unstructuredList{elements: l, itemsSchema: schema.Items.Schema} - listType := getXListType(schema) + typedList := unstructuredList{elements: l, itemsSchema: schema.Items()} + listType := schema.XListType() if listType != "" { switch listType { case "map": - mapKeys := getXListMapKeys(schema) + mapKeys := schema.XListMapKeys() return &unstructuredMapList{unstructuredList: typedList, escapedKeyProps: escapeKeyProps(mapKeys)} case "set": return &unstructuredSetList{unstructuredList: typedList} @@ -124,12 +123,12 @@ func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { return &typedList } - if schema.Type.Contains("string") { + if schema.Type() == "string" { str, ok := unstructured.(string) if !ok { return types.NewErr("invalid data, expected string, got %T", unstructured) } - switch schema.Format { + switch schema.Format() { case "duration": d, err := strfmt.ParseDuration(str) if err != nil { @@ -159,7 +158,7 @@ func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { return types.String(str) } - if schema.Type.Contains("number") { + if schema.Type() == "number" { switch v := unstructured.(type) { // float representations of whole numbers (e.g. 1.0, 0.0) can convert to int representations (e.g. 1, 0) in yaml // to json translation, and then get parsed as int64s @@ -178,7 +177,7 @@ func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { return types.NewErr("invalid data, expected float, got %T", unstructured) } } - if schema.Type.Contains("integer") { + if schema.Type() == "integer" { switch v := unstructured.(type) { case int: return types.Int(v) @@ -190,7 +189,7 @@ func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { return types.NewErr("invalid data, expected int, got %T", unstructured) } } - if schema.Type.Contains("boolean") { + if schema.Type() == "boolean" { b, ok := unstructured.(bool) if !ok { return types.NewErr("invalid data, expected bool, got %T", unstructured) @@ -198,11 +197,11 @@ func UnstructuredToVal(unstructured interface{}, schema *spec.Schema) ref.Val { return types.Bool(b) } - if isXPreserveUnknownFields(schema) { + if schema.IsXPreserveUnknownFields() { return &unknownPreserved{u: unstructured} } - return types.NewErr("invalid type, expected object, array, number, integer, boolean or string, or no type with x-kubernetes-int-or-string or x-kubernetes-preserve-unknown-fields is true, got %s", schema.Type) + return types.NewErr("invalid type, expected object, array, number, integer, boolean or string, or no type with x-kubernetes-int-or-string or x-kubernetes-preserve-unknown-fields is true, got %s", schema.Type()) } // unknownPreserved represents unknown data preserved in custom resources via x-kubernetes-preserve-unknown-fields. @@ -418,7 +417,7 @@ func (t *unstructuredSetList) Add(other ref.Val) ref.Val { // unstructuredList represents an unstructured data instance of an OpenAPI array with x-kubernetes-list-type=atomic (the default). type unstructuredList struct { elements []interface{} - itemsSchema *spec.Schema + itemsSchema Schema } var _ = traits.Lister(&unstructuredList{}) @@ -548,9 +547,9 @@ func (t *unstructuredList) Size() ref.Val { // unstructuredMap represented an unstructured data instance of an OpenAPI object. type unstructuredMap struct { value map[string]interface{} - schema *spec.Schema + schema Schema // propSchema finds the schema to use for a particular map key. - propSchema func(key string) (*spec.Schema, bool) + propSchema func(key string) (Schema, bool) } var _ = traits.Mapper(&unstructuredMap{}) @@ -636,7 +635,7 @@ func (t *unstructuredMap) Get(key ref.Val) ref.Val { } func (t *unstructuredMap) Iterator() traits.Iterator { - isObject := t.schema.Properties != nil + isObject := t.schema.Properties() != nil keys := make([]ref.Val, len(t.value)) i := 0 for k := range t.value { @@ -675,7 +674,7 @@ func (t *unstructuredMap) Size() ref.Val { } func (t *unstructuredMap) Find(key ref.Val) (ref.Val, bool) { - isObject := t.schema.Properties != nil + isObject := t.schema.Properties() != nil keyStr, ok := key.(types.String) if !ok { return types.MaybeNoSuchOverloadErr(key), true diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/adaptor.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/adaptor.go new file mode 100644 index 00000000000..0e2cc6e2b2e --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/adaptor.go @@ -0,0 +1,147 @@ +/* +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 ( + "github.com/google/cel-go/common/types/ref" + + apiservercel "k8s.io/apiserver/pkg/cel" + "k8s.io/apiserver/pkg/cel/common" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +var _ common.Schema = (*Schema)(nil) +var _ common.SchemaOrBool = (*SchemaOrBool)(nil) + +type Schema struct { + Schema *spec.Schema +} + +type SchemaOrBool struct { + SchemaOrBool *spec.SchemaOrBool +} + +func (sb *SchemaOrBool) Schema() common.Schema { + return &Schema{Schema: sb.SchemaOrBool.Schema} +} + +func (sb *SchemaOrBool) Allows() bool { + return sb.SchemaOrBool.Allows +} + +func (s *Schema) Type() string { + if len(s.Schema.Type) == 0 { + return "" + } + return s.Schema.Type[0] +} + +func (s *Schema) Format() string { + return s.Schema.Format +} + +func (s *Schema) Items() common.Schema { + if s.Schema.Items == nil || s.Schema.Items.Schema == nil { + return nil + } + return &Schema{Schema: s.Schema.Items.Schema} +} + +func (s *Schema) Properties() map[string]common.Schema { + if s.Schema.Properties == nil { + return nil + } + res := make(map[string]common.Schema, len(s.Schema.Properties)) + for n, prop := range s.Schema.Properties { + // map value is unaddressable, create a shallow copy + // this is a shallow non-recursive copy + s := prop + res[n] = &Schema{Schema: &s} + } + return res +} + +func (s *Schema) AdditionalProperties() common.SchemaOrBool { + if s.Schema.AdditionalProperties == nil { + return nil + } + return &SchemaOrBool{SchemaOrBool: s.Schema.AdditionalProperties} +} + +func (s *Schema) Default() any { + return s.Schema.Default +} + +func (s *Schema) MaxItems() *int64 { + return s.Schema.MaxItems +} + +func (s *Schema) MaxLength() *int64 { + return s.Schema.MaxLength +} + +func (s *Schema) MaxProperties() *int64 { + return s.Schema.MaxProperties +} + +func (s *Schema) Required() []string { + return s.Schema.Required +} + +func (s *Schema) Enum() []any { + return s.Schema.Enum +} + +func (s *Schema) Nullable() bool { + return s.Schema.Nullable +} + +func (s *Schema) IsXIntOrString() bool { + return isXIntOrString(s.Schema) +} + +func (s *Schema) IsXEmbeddedResource() bool { + return isXEmbeddedResource(s.Schema) +} + +func (s *Schema) IsXPreserveUnknownFields() bool { + return isXPreserveUnknownFields(s.Schema) +} + +func (s *Schema) XListType() string { + return getXListType(s.Schema) +} + +func (s *Schema) XListMapKeys() []string { + return getXListMapKeys(s.Schema) +} + +func (s *Schema) WithTypeAndObjectMeta() common.Schema { + return &Schema{common.WithTypeAndObjectMeta(s.Schema)} +} + +func UnstructuredToVal(unstructured any, schema *spec.Schema) ref.Val { + return common.UnstructuredToVal(unstructured, &Schema{schema}) +} + +func SchemaDeclType(s *spec.Schema, isResourceRoot bool) *apiservercel.DeclType { + return common.SchemaDeclType(&Schema{Schema: s}, isResourceRoot) +} + +func MakeMapList(sts *spec.Schema, items []interface{}) (rv common.MapList) { + return common.MakeMapList(&Schema{Schema: sts}, items) +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/extensions.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/extensions.go index e7817cfe230..6a2f830320b 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/openapi/extensions.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/extensions.go @@ -29,6 +29,8 @@ func isExtension(schema *spec.Schema, key string) bool { } func isXIntOrString(schema *spec.Schema) bool { + // built-in types have the Format while CRDs use extension + // both are valid, checking both return schema.Format == intOrStringFormat || isExtension(schema, extIntOrString) } @@ -46,32 +48,11 @@ func getXListType(schema *spec.Schema) string { } func getXListMapKeys(schema *spec.Schema) []string { - items, ok := schema.Extensions[extListMapKeys] + mapKeys, ok := schema.Extensions.GetStringSlice(extListMapKeys) if !ok { return nil } - // items may be any of - // - a slice of string - // - a slice of interface{}, a.k.a any, but item's real type is string - // there is no direct conversion, so do that manually - switch items.(type) { - case []string: - return items.([]string) - case []any: - a := items.([]any) - result := make([]string, 0, len(a)) - for _, item := range a { - // item must be a string - s, ok := item.(string) - if !ok { - return nil - } - result = append(result, s) - } - return result - } - // no further attempt of handling unexpected type - return nil + return mapKeys } const extIntOrString = "x-kubernetes-int-or-string" From 7106d42a275e959d55e407180668873451399d81 Mon Sep 17 00:00:00 2001 From: Jiahui Feng Date: Tue, 7 Feb 2023 18:03:58 -0800 Subject: [PATCH 7/8] avoid deepcopying schemas. --- .../pkg/cel/openapi/resolver/definitions.go | 40 ++----- .../pkg/cel/openapi/resolver/discovery.go | 51 +-------- .../pkg/cel/openapi/resolver/refs.go | 100 ++++++++++++++++++ 3 files changed, 108 insertions(+), 83 deletions(-) create mode 100644 staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/refs.go diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/definitions.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/definitions.go index 170b2c86b39..df7357f7785 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/definitions.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/definitions.go @@ -17,7 +17,6 @@ limitations under the License. package resolver import ( - "encoding/json" "fmt" "k8s.io/apimachinery/pkg/runtime" @@ -47,8 +46,9 @@ func NewDefinitionsSchemaResolver(scheme *runtime.Scheme, getDefinitions common. for name, def := range defs { _, e := namer.GetDefinitionName(name) gvks := extensionsToGVKs(e) + s := def.Schema // map value not addressable, make copy for _, gvk := range gvks { - gvkToSchema[gvk] = &def.Schema + gvkToSchema[gvk] = &s } } return &DefinitionsSchemaResolver{ @@ -62,45 +62,19 @@ func (d *DefinitionsSchemaResolver) ResolveSchema(gvk schema.GroupVersionKind) ( if !ok { return nil, fmt.Errorf("cannot resolve %v: %w", gvk, ErrSchemaNotFound) } - result, err := deepCopy(s) - if err != nil { - return nil, fmt.Errorf("cannot deep copy schema for %v: %v", gvk, err) - } - err = populateRefs(func(ref string) (*spec.Schema, bool) { + s, err := populateRefs(func(ref string) (*spec.Schema, bool) { // find the schema by the ref string, and return a deep copy def, ok := d.defs[ref] if !ok { return nil, false } - s, err := deepCopy(&def.Schema) - if err != nil { - return nil, false - } - return s, true - }, result) + s := def.Schema + return &s, true + }, s) if err != nil { return nil, err } - return result, nil -} - -// deepCopy generates a deep copy of the given schema with JSON marshalling and -// unmarshalling. -// The schema is expected to be "shallow", with all its field being Refs instead -// of nested schemas. -// If the schema contains cyclic reference, for example, a properties is itself -// it will return an error. This resolver does not support such condition. -func deepCopy(s *spec.Schema) (*spec.Schema, error) { - b, err := json.Marshal(s) - if err != nil { - return nil, err - } - result := new(spec.Schema) - err = json.Unmarshal(b, result) - if err != nil { - return nil, err - } - return result, nil + return s, nil } func extensionsToGVKs(extensions spec.Extensions) []schema.GroupVersionKind { diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/discovery.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/discovery.go index 75f9fd6dc99..53cbc7054b3 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/discovery.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/discovery.go @@ -57,7 +57,7 @@ func (r *ClientDiscoveryResolver) ResolveSchema(gvk schema.GroupVersionKind) (*s if err != nil { return nil, err } - err = populateRefs(func(ref string) (*spec.Schema, bool) { + s, err = populateRefs(func(ref string) (*spec.Schema, bool) { s, ok := resp.Components.Schemas[strings.TrimPrefix(ref, refPrefix)] return s, ok }, s) @@ -67,55 +67,6 @@ func (r *ClientDiscoveryResolver) ResolveSchema(gvk schema.GroupVersionKind) (*s return s, nil } -func populateRefs(schemaOf func(ref string) (*spec.Schema, bool), schema *spec.Schema) error { - ref, isRef := refOf(schema) - if isRef { - // replace the whole schema with the referred one. - resolved, ok := schemaOf(ref) - if !ok { - return fmt.Errorf("internal error: cannot resolve Ref %q: %w", ref, ErrSchemaNotFound) - } - *schema = *resolved - } - // schema is an object, populate its properties and additionalProperties - for name, prop := range schema.Properties { - err := populateRefs(schemaOf, &prop) - if err != nil { - return err - } - schema.Properties[name] = prop - } - if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { - err := populateRefs(schemaOf, schema.AdditionalProperties.Schema) - if err != nil { - return err - } - } - // schema is a list, populate its items - if schema.Items != nil && schema.Items.Schema != nil { - err := populateRefs(schemaOf, schema.Items.Schema) - if err != nil { - return err - } - } - return nil -} - -func refOf(schema *spec.Schema) (string, bool) { - if schema.Ref.GetURL() != nil { - return schema.Ref.String(), true - } - // A Ref may be wrapped in allOf to preserve its description - // see https://github.com/kubernetes/kubernetes/issues/106387 - // For kube-openapi, allOf is only used for wrapping a Ref. - for _, allOf := range schema.AllOf { - if ref, isRef := refOf(&allOf); isRef { - return ref, isRef - } - } - return "", false -} - func resolveType(resp *schemaResponse, gvk schema.GroupVersionKind) (*spec.Schema, error) { for _, s := range resp.Components.Schemas { var gvks []schema.GroupVersionKind diff --git a/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/refs.go b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/refs.go new file mode 100644 index 00000000000..49321bab47d --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/openapi/resolver/refs.go @@ -0,0 +1,100 @@ +/* +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 resolver + +import ( + "fmt" + + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// populateRefs recursively replaces Refs in the schema with the referred one. +// schemaOf is the callback to find the corresponding schema by the ref. +// This function will not mutate the original schema. If the schema needs to be +// mutated, a copy will be returned, otherwise it returns the original schema. +func populateRefs(schemaOf func(ref string) (*spec.Schema, bool), schema *spec.Schema) (*spec.Schema, error) { + result := *schema + changed := false + + ref, isRef := refOf(schema) + if isRef { + // replace the whole schema with the referred one. + resolved, ok := schemaOf(ref) + if !ok { + return nil, fmt.Errorf("internal error: cannot resolve Ref %q: %w", ref, ErrSchemaNotFound) + } + result = *resolved + changed = true + } + // schema is an object, populate its properties and additionalProperties + props := make(map[string]spec.Schema, len(schema.Properties)) + propsChanged := false + for name, prop := range result.Properties { + populated, err := populateRefs(schemaOf, &prop) + if err != nil { + return nil, err + } + if populated != &prop { + propsChanged = true + } + props[name] = *populated + } + if propsChanged { + changed = true + result.Properties = props + } + if result.AdditionalProperties != nil && result.AdditionalProperties.Schema != nil { + populated, err := populateRefs(schemaOf, result.AdditionalProperties.Schema) + if err != nil { + return nil, err + } + if populated != result.AdditionalProperties.Schema { + changed = true + result.AdditionalProperties.Schema = populated + } + } + // schema is a list, populate its items + if result.Items != nil && result.Items.Schema != nil { + populated, err := populateRefs(schemaOf, result.Items.Schema) + if err != nil { + return nil, err + } + if populated != result.Items.Schema { + changed = true + result.Items.Schema = populated + } + } + if changed { + return &result, nil + } + return schema, nil +} + +func refOf(schema *spec.Schema) (string, bool) { + if schema.Ref.GetURL() != nil { + return schema.Ref.String(), true + } + // A Ref may be wrapped in allOf to preserve its description + // see https://github.com/kubernetes/kubernetes/issues/106387 + // For kube-openapi, allOf is only used for wrapping a Ref. + for _, allOf := range schema.AllOf { + if ref, isRef := refOf(&allOf); isRef { + return ref, isRef + } + } + return "", false +} From b29f618ca7e35a5116ab8c36f3f819ea5ee44ae5 Mon Sep 17 00:00:00 2001 From: Jiahui Feng Date: Tue, 7 Feb 2023 15:57:05 -0800 Subject: [PATCH 8/8] generated: ./hack/update-vendor.sh --- go.mod | 2 +- staging/src/k8s.io/apiserver/go.mod | 2 ++ staging/src/k8s.io/apiserver/go.sum | 4 ++++ vendor/modules.txt | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index c9b74c1592e..f7adffcb1ec 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.2 github.com/google/cadvisor v0.47.1 + github.com/google/cel-go v0.12.6 github.com/google/gnostic v0.5.7-v3refs github.com/google/go-cmp v0.5.9 github.com/google/gofuzz v1.1.0 @@ -171,7 +172,6 @@ require ( github.com/gofrs/uuid v4.0.0+incompatible // indirect github.com/golang-jwt/jwt/v4 v4.2.0 // indirect github.com/google/btree v1.0.1 // indirect - github.com/google/cel-go v0.12.6 // indirect github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/gax-go/v2 v2.1.1 // indirect diff --git a/staging/src/k8s.io/apiserver/go.mod b/staging/src/k8s.io/apiserver/go.mod index bbd9389c7ee..64cbcf09ca2 100644 --- a/staging/src/k8s.io/apiserver/go.mod +++ b/staging/src/k8s.io/apiserver/go.mod @@ -60,6 +60,7 @@ require ( cloud.google.com/go v0.97.0 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect + github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.1.3 // indirect @@ -87,6 +88,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect + github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/staging/src/k8s.io/apiserver/go.sum b/staging/src/k8s.io/apiserver/go.sum index 94bf0042c45..93ae44ecb5e 100644 --- a/staging/src/k8s.io/apiserver/go.sum +++ b/staging/src/k8s.io/apiserver/go.sum @@ -62,6 +62,8 @@ github.com/antlr/antlr4/runtime/Go/antlr v1.4.10/go.mod h1:F7bn7fEU90QkQ3tnmaTx3 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -355,6 +357,8 @@ github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS4 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/vendor/modules.txt b/vendor/modules.txt index dd2bb8746e6..c03972f4bc9 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1507,8 +1507,11 @@ k8s.io/apiserver/pkg/authorization/authorizerfactory k8s.io/apiserver/pkg/authorization/path k8s.io/apiserver/pkg/authorization/union k8s.io/apiserver/pkg/cel +k8s.io/apiserver/pkg/cel/common k8s.io/apiserver/pkg/cel/library k8s.io/apiserver/pkg/cel/metrics +k8s.io/apiserver/pkg/cel/openapi +k8s.io/apiserver/pkg/cel/openapi/resolver k8s.io/apiserver/pkg/endpoints k8s.io/apiserver/pkg/endpoints/deprecation k8s.io/apiserver/pkg/endpoints/discovery