mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-24 12:15:52 +00:00
Enable feature gated custom resource validation using validation rules
This commit is contained in:
parent
f0a80eda46
commit
0e0468b75e
@ -0,0 +1,223 @@
|
||||
/*
|
||||
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 cel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"github.com/google/cel-go/interpreter"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
"k8s.io/apiextensions-apiserver/third_party/forked/celopenapi/model"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
)
|
||||
|
||||
// Validator parallels the structure of schema.Structural and includes the compiled CEL programs
|
||||
// for the x-kubernetes-validations of each schema node.
|
||||
type Validator struct {
|
||||
Items *Validator
|
||||
Properties map[string]Validator
|
||||
|
||||
AdditionalProperties *Validator
|
||||
|
||||
compiledRules []CompilationResult
|
||||
|
||||
// Program compilation is pre-checked at CRD creation/update time, so we don't expect compilation to fail
|
||||
// they are recompiled and added to this type, and it does, it is an internal bug.
|
||||
// But if somehow we get any compilation errors, we track them and then surface them as validation errors.
|
||||
compilationErr error
|
||||
|
||||
// isResourceRoot is true if this validator node is for the root of a resource. Either the root of the
|
||||
// custom resource being validated, or the root of an XEmbeddedResource object.
|
||||
isResourceRoot bool
|
||||
}
|
||||
|
||||
// NewValidator returns compiles all the CEL programs defined in x-kubernetes-validations extensions
|
||||
// of the Structural schema and returns a custom resource validator that contains nested
|
||||
// validators for all items, properties and additionalProperties that transitively contain validator rules.
|
||||
// Returns nil only if there no validator rules in the Structural schema. May return a validator containing
|
||||
// only errors.
|
||||
func NewValidator(s *schema.Structural) *Validator {
|
||||
return validator(s, true)
|
||||
}
|
||||
|
||||
func validator(s *schema.Structural, isResourceRoot bool) *Validator {
|
||||
compiledRules, err := Compile(s, isResourceRoot)
|
||||
var itemsValidator, additionalPropertiesValidator *Validator
|
||||
var propertiesValidators map[string]Validator
|
||||
if s.Items != nil {
|
||||
itemsValidator = validator(s.Items, s.Items.XEmbeddedResource)
|
||||
}
|
||||
if len(s.Properties) > 0 {
|
||||
propertiesValidators = make(map[string]Validator, len(s.Properties))
|
||||
for k, prop := range s.Properties {
|
||||
if p := validator(&prop, prop.XEmbeddedResource); p != nil {
|
||||
propertiesValidators[k] = *p
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil {
|
||||
additionalPropertiesValidator = validator(s.AdditionalProperties.Structural, s.AdditionalProperties.Structural.XEmbeddedResource)
|
||||
}
|
||||
if len(compiledRules) > 0 || err != nil || itemsValidator != nil || additionalPropertiesValidator != nil || len(propertiesValidators) > 0 {
|
||||
return &Validator{
|
||||
compiledRules: compiledRules,
|
||||
compilationErr: err,
|
||||
isResourceRoot: isResourceRoot,
|
||||
Items: itemsValidator,
|
||||
AdditionalProperties: additionalPropertiesValidator,
|
||||
Properties: propertiesValidators,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates all x-kubernetes-validations rules in Validator against obj and returns any errors.
|
||||
func (s *Validator) Validate(fldPath *field.Path, sts *schema.Structural, obj interface{}) field.ErrorList {
|
||||
if s == nil || obj == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errs := s.validateExpressions(fldPath, sts, obj)
|
||||
switch obj := obj.(type) {
|
||||
case []interface{}:
|
||||
return append(errs, s.validateArray(fldPath, sts, obj)...)
|
||||
case map[string]interface{}:
|
||||
return append(errs, s.validateMap(fldPath, sts, obj)...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func (s *Validator) validateExpressions(fldPath *field.Path, sts *schema.Structural, obj interface{}) (errs field.ErrorList) {
|
||||
if obj == nil {
|
||||
// We only validate non-null values. Rules that need to check for the state of a nullable value or the presence of an optional
|
||||
// field must do so from the surrounding schema. E.g. if an array has nullable string items, a rule on the array
|
||||
// schema can check if items are null, but a rule on the nullable string schema only validates the non-null strings.
|
||||
return nil
|
||||
}
|
||||
if s.compilationErr != nil {
|
||||
errs = append(errs, field.Invalid(fldPath, obj, fmt.Sprintf("rule compiler initialization error: %v", s.compilationErr)))
|
||||
return errs
|
||||
}
|
||||
if len(s.compiledRules) == 0 {
|
||||
return nil // nothing to do
|
||||
}
|
||||
if s.isResourceRoot {
|
||||
sts = model.WithTypeAndObjectMeta(sts)
|
||||
}
|
||||
activation := NewValidationActivation(obj, sts)
|
||||
for i, compiled := range s.compiledRules {
|
||||
rule := sts.XValidations[i]
|
||||
if compiled.Error != nil {
|
||||
errs = append(errs, field.Invalid(fldPath, obj, fmt.Sprintf("rule compile error: %v", compiled.Error)))
|
||||
continue
|
||||
}
|
||||
if compiled.Program == nil {
|
||||
// rule is empty
|
||||
continue
|
||||
}
|
||||
evalResult, _, err := compiled.Program.Eval(activation)
|
||||
if err != nil {
|
||||
// see types.Err for list of well defined error types
|
||||
if strings.HasPrefix(err.Error(), "no such overload") {
|
||||
// Most overload errors are caught by the compiler, which provides details on where exactly in the rule
|
||||
// error was found. Here, an overload error has occurred at runtime no details are provided, so we
|
||||
// append a more descriptive error message. This error can only occur when static type checking has
|
||||
// been bypassed. int-or-string is typed as dynamic and so bypasses compiler type checking.
|
||||
errs = append(errs, field.Invalid(fldPath, obj, fmt.Sprintf("'%v': call arguments did not match a supported operator, function or macro signature for rule: %v", err, ruleErrorString(rule))))
|
||||
} else {
|
||||
// no such key: {key}, index out of bounds: {index}, integer overflow, division by zero, ...
|
||||
errs = append(errs, field.Invalid(fldPath, obj, fmt.Sprintf("%v evaluating rule: %v", err, ruleErrorString(rule))))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if evalResult != types.True {
|
||||
if len(rule.Message) != 0 {
|
||||
errs = append(errs, field.Invalid(fldPath, obj, rule.Message))
|
||||
} else {
|
||||
errs = append(errs, field.Invalid(fldPath, obj, fmt.Sprintf("failed rule: %s", ruleErrorString(rule))))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func ruleErrorString(rule apiextensions.ValidationRule) string {
|
||||
if len(rule.Message) > 0 {
|
||||
return strings.TrimSpace(rule.Message)
|
||||
}
|
||||
return strings.TrimSpace(rule.Rule)
|
||||
}
|
||||
|
||||
type validationActivation struct {
|
||||
self ref.Val
|
||||
}
|
||||
|
||||
func NewValidationActivation(obj interface{}, structural *schema.Structural) *validationActivation {
|
||||
return &validationActivation{self: UnstructuredToVal(obj, structural)}
|
||||
}
|
||||
|
||||
func (a *validationActivation) ResolveName(name string) (interface{}, bool) {
|
||||
if name == ScopedVarName {
|
||||
return a.self, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (a *validationActivation) Parent() interpreter.Activation {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Validator) validateMap(fldPath *field.Path, sts *schema.Structural, obj map[string]interface{}) (errs field.ErrorList) {
|
||||
if s == nil || obj == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.AdditionalProperties != nil && sts.AdditionalProperties != nil && sts.AdditionalProperties.Structural != nil {
|
||||
for k, v := range obj {
|
||||
errs = append(errs, s.AdditionalProperties.Validate(fldPath.Key(k), sts.AdditionalProperties.Structural, v)...)
|
||||
}
|
||||
}
|
||||
if s.Properties != nil && sts.Properties != nil {
|
||||
for k, v := range obj {
|
||||
stsProp, stsOk := sts.Properties[k]
|
||||
sub, ok := s.Properties[k]
|
||||
if ok && stsOk {
|
||||
errs = append(errs, sub.Validate(fldPath.Child(k), &stsProp, v)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func (s *Validator) validateArray(fldPath *field.Path, sts *schema.Structural, obj []interface{}) field.ErrorList {
|
||||
var errs field.ErrorList
|
||||
|
||||
if s.Items != nil && sts.Items != nil {
|
||||
for i := range obj {
|
||||
errs = append(errs, s.Items.Validate(fldPath.Index(i), sts.Items, obj[i])...)
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,703 @@
|
||||
/*
|
||||
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 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"
|
||||
"k8s.io/apimachinery/pkg/api/equality"
|
||||
|
||||
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
"k8s.io/apiextensions-apiserver/third_party/forked/celopenapi/model"
|
||||
"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 map[string]interface{} to match 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-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 []interface{} to match 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 := model.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 := model.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 = model.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
|
||||
}
|
@ -0,0 +1,621 @@
|
||||
/*
|
||||
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 cel
|
||||
|
||||
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/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
)
|
||||
|
||||
var (
|
||||
listTypeSet = "set"
|
||||
listTypeMap = "map"
|
||||
stringSchema = schema.Structural{
|
||||
Generic: schema.Generic{
|
||||
Type: "string",
|
||||
},
|
||||
}
|
||||
intSchema = schema.Structural{
|
||||
Generic: schema.Generic{
|
||||
Type: "integer",
|
||||
},
|
||||
ValueValidation: &schema.ValueValidation{
|
||||
Format: "int64",
|
||||
},
|
||||
}
|
||||
mapListElementSchema = schema.Structural{
|
||||
Generic: schema.Generic{
|
||||
Type: "object",
|
||||
},
|
||||
Properties: map[string]schema.Structural{
|
||||
"key": stringSchema,
|
||||
"val": intSchema,
|
||||
},
|
||||
}
|
||||
mapListSchema = schema.Structural{
|
||||
Extensions: schema.Extensions{XListType: &listTypeMap, XListMapKeys: []string{"key"}},
|
||||
Generic: schema.Generic{
|
||||
Type: "array",
|
||||
},
|
||||
Items: &mapListElementSchema,
|
||||
}
|
||||
multiKeyMapListSchema = schema.Structural{
|
||||
Extensions: schema.Extensions{XListType: &listTypeMap, XListMapKeys: []string{"key1", "key2"}},
|
||||
Generic: schema.Generic{
|
||||
Type: "array",
|
||||
},
|
||||
Items: &schema.Structural{
|
||||
Generic: schema.Generic{
|
||||
Type: "object",
|
||||
},
|
||||
Properties: map[string]schema.Structural{
|
||||
"key1": stringSchema,
|
||||
"key2": stringSchema,
|
||||
"val": intSchema,
|
||||
},
|
||||
},
|
||||
}
|
||||
setListSchema = schema.Structural{
|
||||
Extensions: schema.Extensions{XListType: &listTypeSet},
|
||||
Generic: schema.Generic{
|
||||
Type: "array",
|
||||
},
|
||||
Items: &stringSchema,
|
||||
}
|
||||
atomicListSchema = schema.Structural{
|
||||
Generic: schema.Generic{
|
||||
Type: "array",
|
||||
},
|
||||
Items: &stringSchema,
|
||||
}
|
||||
objectSchema = schema.Structural{
|
||||
Generic: schema.Generic{
|
||||
Type: "object",
|
||||
},
|
||||
Properties: map[string]schema.Structural{
|
||||
"field1": stringSchema,
|
||||
"field2": stringSchema,
|
||||
},
|
||||
}
|
||||
mapSchema = schema.Structural{
|
||||
Generic: schema.Generic{
|
||||
Type: "object",
|
||||
AdditionalProperties: &schema.StructuralOrBool{
|
||||
Bool: true,
|
||||
Structural: &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(<native type>) to return %v", tc.equal)
|
||||
}
|
||||
if tc.rhs.Equal(types.DefaultTypeAdapter.NativeToValue(tc.lhs.Value())) != types.Bool(tc.equal) {
|
||||
t.Errorf("expected unstructuredVal.Equals(<native type>) to return %v", tc.equal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLister(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
unstructured []interface{}
|
||||
schema *schema.Structural
|
||||
itemSchema *schema.Structural
|
||||
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 *schema.Structural
|
||||
propertySchema func(key string) (*schema.Structural, bool)
|
||||
size int64
|
||||
notContains []ref.Val
|
||||
}{
|
||||
{
|
||||
name: "object",
|
||||
unstructured: map[string]interface{}{
|
||||
"field1": "a",
|
||||
"field2": "b",
|
||||
},
|
||||
schema: &objectSchema,
|
||||
propertySchema: func(key string) (*schema.Structural, 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) (*schema.Structural, bool) { return mapSchema.AdditionalProperties.Structural, 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -21,6 +21,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
|
||||
schemaobjectmeta "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning"
|
||||
apiservervalidation "k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
|
||||
@ -84,6 +85,8 @@ func validate(pth *field.Path, s *structuralschema.Structural, rootSchema *struc
|
||||
allErrs = append(allErrs, field.Invalid(pth.Child("default"), s.Default.Object, fmt.Sprintf("must result in valid metadata: %v", errs.ToAggregate())))
|
||||
} else if errs := apiservervalidation.ValidateCustomResource(pth.Child("default"), s.Default.Object, validator); len(errs) > 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
} else if celValidator := cel.NewValidator(s); celValidator != nil {
|
||||
allErrs = append(allErrs, celValidator.Validate(pth.Child("default"), s, s.Default.Object)...)
|
||||
}
|
||||
} else {
|
||||
// check whether default is pruned
|
||||
@ -102,6 +105,8 @@ func validate(pth *field.Path, s *structuralschema.Structural, rootSchema *struc
|
||||
allErrs = append(allErrs, errs...)
|
||||
} else if errs := apiservervalidation.ValidateCustomResource(pth.Child("default"), s.Default.Object, validator); len(errs) > 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
} else if celValidator := cel.NewValidator(s); celValidator != nil {
|
||||
allErrs = append(allErrs, celValidator.Validate(pth.Child("default"), s, s.Default.Object)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -75,6 +75,7 @@ func validateListSetsAndMapsArray(fldPath *field.Path, s *schema.Structural, obj
|
||||
case "map":
|
||||
errs = append(errs, validateListMap(fldPath, s, obj)...)
|
||||
}
|
||||
// if a case is ever added here then one should also be added to pkg/apiserver/schema/cel/values.go
|
||||
}
|
||||
|
||||
if s.Items != nil {
|
||||
|
@ -81,7 +81,19 @@ func (a statusStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.O
|
||||
|
||||
// ValidateUpdate is the default update validation for an end user updating status.
|
||||
func (a statusStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
|
||||
return a.customResourceStrategy.validator.ValidateStatusUpdate(ctx, obj, old, a.scale)
|
||||
var errs field.ErrorList
|
||||
errs = append(errs, a.customResourceStrategy.validator.ValidateStatusUpdate(ctx, obj, old, a.scale)...)
|
||||
|
||||
// validate embedded resources
|
||||
if u, ok := obj.(*unstructured.Unstructured); ok {
|
||||
v := obj.GetObjectKind().GroupVersionKind().Version
|
||||
|
||||
// validate x-kubernetes-validations rules
|
||||
if celValidator, ok := a.customResourceStrategy.celValidators[v]; ok {
|
||||
errs = append(errs, celValidator.Validate(nil, a.customResourceStrategy.structuralSchemas[v], u.Object)...)
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// WarningsOnUpdate returns warnings for the given update.
|
||||
|
@ -19,6 +19,11 @@ package customresource
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
|
||||
structurallisttype "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/listtype"
|
||||
schemaobjectmeta "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@ -28,14 +33,12 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/features"
|
||||
apiserverstorage "k8s.io/apiserver/pkg/storage"
|
||||
"k8s.io/apiserver/pkg/storage/names"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
"k8s.io/kube-openapi/pkg/validation/validate"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
structurallisttype "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/listtype"
|
||||
schemaobjectmeta "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta"
|
||||
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
|
||||
)
|
||||
|
||||
@ -47,12 +50,23 @@ type customResourceStrategy struct {
|
||||
namespaceScoped bool
|
||||
validator customResourceValidator
|
||||
structuralSchemas map[string]*structuralschema.Structural
|
||||
celValidators map[string]*cel.Validator
|
||||
status *apiextensions.CustomResourceSubresourceStatus
|
||||
scale *apiextensions.CustomResourceSubresourceScale
|
||||
kind schema.GroupVersionKind
|
||||
}
|
||||
|
||||
func NewStrategy(typer runtime.ObjectTyper, namespaceScoped bool, kind schema.GroupVersionKind, schemaValidator, statusSchemaValidator *validate.SchemaValidator, structuralSchemas map[string]*structuralschema.Structural, status *apiextensions.CustomResourceSubresourceStatus, scale *apiextensions.CustomResourceSubresourceScale) customResourceStrategy {
|
||||
celValidators := map[string]*cel.Validator{}
|
||||
if utilfeature.DefaultFeatureGate.Enabled(features.CustomResourceValidationExpressions) {
|
||||
for name, s := range structuralSchemas {
|
||||
v := cel.NewValidator(s) // CEL programs are compiled and cached here
|
||||
if v != nil {
|
||||
celValidators[name] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return customResourceStrategy{
|
||||
ObjectTyper: typer,
|
||||
NameGenerator: names.SimpleNameGenerator,
|
||||
@ -66,6 +80,7 @@ func NewStrategy(typer runtime.ObjectTyper, namespaceScoped bool, kind schema.Gr
|
||||
statusSchemaValidator: statusSchemaValidator,
|
||||
},
|
||||
structuralSchemas: structuralSchemas,
|
||||
celValidators: celValidators,
|
||||
kind: kind,
|
||||
}
|
||||
}
|
||||
@ -156,6 +171,11 @@ func (a customResourceStrategy) Validate(ctx context.Context, obj runtime.Object
|
||||
|
||||
// validate x-kubernetes-list-type "map" and "set" invariant
|
||||
errs = append(errs, structurallisttype.ValidateListSetsAndMaps(nil, a.structuralSchemas[v], u.Object)...)
|
||||
|
||||
// validate x-kubernetes-validations rules
|
||||
if celValidator, ok := a.celValidators[v]; ok {
|
||||
errs = append(errs, celValidator.Validate(nil, a.structuralSchemas[v], u.Object)...)
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
@ -204,6 +224,11 @@ func (a customResourceStrategy) ValidateUpdate(ctx context.Context, obj, old run
|
||||
errs = append(errs, structurallisttype.ValidateListSetsAndMaps(nil, a.structuralSchemas[v], uNew.Object)...)
|
||||
}
|
||||
|
||||
// validate x-kubernetes-validations rules
|
||||
if celValidator, ok := a.celValidators[v]; ok {
|
||||
errs = append(errs, celValidator.Validate(nil, a.structuralSchemas[v], uNew.Object)...)
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
|
1
vendor/modules.txt
vendored
1
vendor/modules.txt
vendored
@ -1354,6 +1354,7 @@ k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation
|
||||
k8s.io/apiextensions-apiserver/pkg/apiserver
|
||||
k8s.io/apiextensions-apiserver/pkg/apiserver/conversion
|
||||
k8s.io/apiextensions-apiserver/pkg/apiserver/schema
|
||||
k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel
|
||||
k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting
|
||||
k8s.io/apiextensions-apiserver/pkg/apiserver/schema/listtype
|
||||
k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta
|
||||
|
Loading…
Reference in New Issue
Block a user