diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/constants.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/constants.go new file mode 100644 index 00000000000..5eaa3a1b1b6 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/constants.go @@ -0,0 +1,27 @@ +/* +Copyright 2024 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 + +import ( + "github.com/google/cel-go/common/types/traits" +) + +// RootTypeReferenceName is the root reference that all type names should start with. +const RootTypeReferenceName = "Object" + +// ObjectTraits is the bitmask that represents traits that an object should have. +const ObjectTraits = traits.ContainerType diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/interface.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/interface.go new file mode 100644 index 00000000000..b2708a70719 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/interface.go @@ -0,0 +1,45 @@ +/* +Copyright 2024 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 + +import ( + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// TypeResolver resolves a type by a given name. +type TypeResolver interface { + // Resolve resolves the type by its name, starting with "Object" as its root. + // The type that the name refers to must be an object. + // This function returns false if the name does not refer to a known object type. + Resolve(name string) (TypeRef, bool) +} + +// TypeRef refers an object type that can be looked up for its fields. +type TypeRef interface { + ref.Type + + // CELType wraps the TypeRef to be a type that is understood by CEL. + CELType() *types.Type + + // Field finds the field by the field name, or false if the field is not known. + // This function directly return a FieldType that is known to CEL to be more customizable. + Field(name string) (*types.FieldType, bool) + + // Val creates an instance for the TypeRef, given its fields and their values. + Val(fields map[string]ref.Val) ref.Val +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/val.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/val.go new file mode 100644 index 00000000000..ce91accbd15 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/val.go @@ -0,0 +1,119 @@ +/* +Copyright 2024 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 + +import ( + "fmt" + "reflect" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" +) + +// ObjectVal is the CEL Val for an object that is constructed via the object +// construction syntax. +type ObjectVal struct { + typeRef TypeRef + fields map[string]ref.Val +} + +// NewObjectVal creates an ObjectVal by its TypeRef and its fields. +func NewObjectVal(typeRef TypeRef, fields map[string]ref.Val) *ObjectVal { + return &ObjectVal{ + typeRef: typeRef, + fields: fields, + } +} + +var _ ref.Val = (*ObjectVal)(nil) +var _ traits.Zeroer = (*ObjectVal)(nil) + +// ConvertToNative converts the object to map[string]any. +// All nested lists are converted into []any native type. +// +// It returns an error if the target type is not map[string]any, +// or any recursive conversion fails. +func (v *ObjectVal) ConvertToNative(typeDesc reflect.Type) (any, error) { + var result map[string]any + if typeDesc != reflect.TypeOf(result) { + return nil, fmt.Errorf("unable to convert to %v", typeDesc) + } + result = make(map[string]any, len(v.fields)) + for k, v := range v.fields { + result[k] = convertField(v) + } + return result, nil +} + +// ConvertToType supports type conversions between CEL value types supported by the expression language. +func (v *ObjectVal) ConvertToType(typeValue ref.Type) ref.Val { + switch typeValue { + case v.typeRef: + return v + case types.TypeType: + return v.typeRef.CELType() + } + return types.NewErr("unsupported conversion into %v", typeValue) +} + +// Equal returns true if the `other` value has the same type and content as the implementing struct. +func (v *ObjectVal) Equal(other ref.Val) ref.Val { + if rhs, ok := other.(*ObjectVal); ok { + return types.Bool(reflect.DeepEqual(v.fields, rhs.fields)) + } + return types.Bool(false) +} + +// Type returns the TypeValue of the value. +func (v *ObjectVal) Type() ref.Type { + return v.typeRef.CELType() +} + +// Value returns its value as a map[string]any. +func (v *ObjectVal) Value() any { + var result any + var object map[string]any + result, err := v.ConvertToNative(reflect.TypeOf(object)) + if err != nil { + return types.WrapErr(err) + } + return result +} + +// IsZeroValue indicates whether the object is the zero value for the type. +// For the ObjectVal, it is zero value if and only if the fields map is empty. +func (v *ObjectVal) IsZeroValue() bool { + return len(v.fields) == 0 +} + +// convertField converts a referred ref.Val to its expected type. +// For objects, the expected type is map[string]any +// For lists, the expected type is []any +// For anything else, it is converted via value.Value() +func convertField(value ref.Val) any { + // special handling for lists, where the elements are converted with Value() instead of ConvertToNative + // to allow them to become native value of any type. + if listOfVal, ok := value.Value().([]ref.Val); ok { + var result []any + for _, v := range listOfVal { + result = append(result, v.Value()) + } + return result + } + return value.Value() +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/val_test.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/val_test.go new file mode 100644 index 00000000000..b175906d008 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/common/val_test.go @@ -0,0 +1,65 @@ +/* +Copyright 2024 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 + +import ( + "reflect" + "testing" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +func TestOptional(t *testing.T) { + for _, tc := range []struct { + name string + fields map[string]ref.Val + expected map[string]any + }{ + { + name: "present", + fields: map[string]ref.Val{ + "zero": types.OptionalOf(types.IntZero), + }, + expected: map[string]any{ + "zero": int64(0), + }, + }, + { + name: "none", + fields: map[string]ref.Val{ + "absent": types.OptionalNone, + }, + expected: map[string]any{ + // right now no way to differ from a plain null. + // we will need to filter out optional.none() before this conversion. + "absent": nil, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + v := &ObjectVal{ + typeRef: nil, // safe in this test, otherwise put a mock + fields: tc.fields, + } + converted := v.Value() + if !reflect.DeepEqual(tc.expected, converted) { + t.Errorf("wrong result, expected %v but got %v", tc.expected, converted) + } + }) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/env_test.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/env_test.go new file mode 100644 index 00000000000..48bd85c9248 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/env_test.go @@ -0,0 +1,51 @@ +/* +Copyright 2024 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 mutation + +import ( + "testing" + + "github.com/google/cel-go/cel" + + "k8s.io/apimachinery/pkg/util/version" + "k8s.io/apiserver/pkg/cel/environment" +) + +// mustCreateEnv creates the default env for testing, with given option. +// it fatally fails the test if the env fails to set up. +func mustCreateEnv(t testing.TB, envOptions ...cel.EnvOption) *cel.Env { + envSet, err := environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()). + Extend(environment.VersionedOptions{ + IntroducedVersion: version.MajorMinor(1, 30), + EnvOptions: envOptions, + }) + if err != nil { + t.Fatalf("fail to create env set: %v", err) + } + env, err := envSet.Env(environment.StoredExpressions) + if err != nil { + t.Fatalf("fail to setup env: %v", env) + } + return env +} + +// mustCreateEnvWithOptional creates the default env for testing, with given option, +// and set up the optional library with default configuration. +// it fatally fails the test if the env fails to set up. +func mustCreateEnvWithOptional(t testing.TB, envOptions ...cel.EnvOption) *cel.Env { + return mustCreateEnv(t, append(envOptions, cel.OptionalTypes())...) +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/mock_test.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/mock_test.go new file mode 100644 index 00000000000..f8d30bd299e --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/mock_test.go @@ -0,0 +1,110 @@ +/* +Copyright 2024 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 mutation + +import ( + "strings" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + + "k8s.io/apiserver/pkg/cel/mutation/common" +) + +// mockTypeResolver is a mock implementation of TypeResolver that +// allows the object to contain any field. +type mockTypeResolver struct { +} + +// mockTypeRef is a mock implementation of TypeRef that +// contains any field. +type mockTypeRef struct { + objectType *types.Type + resolver common.TypeResolver +} + +func newMockTypeRef(resolver common.TypeResolver, name string) *mockTypeRef { + objectType := types.NewObjectType(name, common.ObjectTraits) + return &mockTypeRef{ + objectType: objectType, + resolver: resolver, + } +} + +func (m *mockTypeRef) HasTrait(trait int) bool { + return common.ObjectTraits|trait != 0 +} + +func (m *mockTypeRef) TypeName() string { + return m.objectType.TypeName() +} + +func (m *mockTypeRef) CELType() *types.Type { + return types.NewTypeTypeWithParam(m.objectType) +} + +func (m *mockTypeRef) Field(name string) (*types.FieldType, bool) { + return &types.FieldType{ + Type: types.DynType, + IsSet: func(target any) bool { + return true + }, + GetFrom: func(target any) (any, error) { + return nil, nil + }, + }, true +} + +func (m *mockTypeRef) Val(fields map[string]ref.Val) ref.Val { + return common.NewObjectVal(m, fields) +} + +func (m *mockTypeResolver) Resolve(name string) (common.TypeRef, bool) { + if strings.HasPrefix(name, common.RootTypeReferenceName) { + return newMockTypeRef(m, name), true + } + return nil, false +} + +// mockTypeResolverForOptional behaves the same as mockTypeResolver +// except returning a mockTypeRefForOptional instead of mockTypeRef +type mockTypeResolverForOptional struct { + *mockTypeResolver +} + +// mockTypeRefForOptional behaves the same as the underlying TypeRef +// except treating "nonExisting" field as non-existing. +// This is used for optional tests. +type mockTypeRefForOptional struct { + common.TypeRef +} + +// Field returns a mock FieldType, or false if the field should not exist. +func (m *mockTypeRefForOptional) Field(name string) (*types.FieldType, bool) { + if name == "nonExisting" { + return nil, false + } + return m.TypeRef.Field(name) +} + +func (m *mockTypeResolverForOptional) Resolve(name string) (common.TypeRef, bool) { + r, ok := m.mockTypeResolver.Resolve(name) + if ok { + return &mockTypeRefForOptional{TypeRef: r}, ok + } + return nil, false +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/optional_test.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/optional_test.go new file mode 100644 index 00000000000..6d47f0100e5 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/optional_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2024 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 mutation + +import ( + "strings" + "testing" + + celtypes "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + + "k8s.io/apiserver/pkg/cel/mutation/common" +) + +// TestCELOptional is an exploration test to demonstrate how CEL optional library +// behave for the use cases that the mutation library requires. +func TestCELOptional(t *testing.T) { + for _, tc := range []struct { + name string + expression string + expectedVal ref.Val + expectedCompileError string + }{ + { + // question mark syntax still requires the field to exist in object construction + name: "construct non-existing field, compile error", + expression: `Object{ + ?nonExisting: optional.none() + }`, + expectedCompileError: `undefined field 'nonExisting'`, + }, + { + // The root cause of the behavior above is that, has on an object (or Message in the Language Def), + // still require the field to be declared in the schema. + // + // Quoting from + // https://github.com/google/cel-spec/blob/master/doc/langdef.md#field-selection + // + // To test for the presence of a field, the boolean-valued macro has(e.f) can be used. + // + // 2. If e evaluates to a message and f is not a declared field for the message, + // has(e.f) raises a no_such_field error. + name: "has(Object{}), de-sugared, compile error", + expression: "has(Object{}.nonExisting)", + expectedCompileError: `undefined field 'nonExisting'`, + }, + { + name: "construct existing field with none, empty object", + expression: `Object{ + ?existing: optional.none() + }`, + expectedVal: common.NewObjectVal(nil, map[string]ref.Val{ + // "existing" field was not set. + }), + }, + { + name: "object of zero value, ofNonZeroValue", + expression: `Object{?spec: optional.ofNonZeroValue(Object.spec{?replicas: Object{}.?replicas})}`, + expectedVal: common.NewObjectVal(nil, map[string]ref.Val{ + // "existing" field was not set. + }), + }, + { + name: "access non-existing field, return none", + expression: `Object{}.?nonExisting`, + expectedCompileError: `undefined field 'nonExisting'`, + }, + { + name: "access existing field, return none", + expression: `Object{}.?existing`, + expectedVal: celtypes.OptionalNone, + }, + { + name: "map non-existing field, return none", + expression: `{"foo": 1}[?"bar"]`, + expectedVal: celtypes.OptionalNone, + }, + { + name: "map existing field, return actual value", + expression: `{"foo": 1}[?"foo"]`, + expectedVal: celtypes.OptionalOf(celtypes.Int(1)), + }, + { + // Map has a different behavior than Object + // + // Quoting from + // https://github.com/google/cel-spec/blob/master/doc/langdef.md#field-selection + // + // To test for the presence of a field, the boolean-valued macro has(e.f) can be used. + // + // 1. If e evaluates to a map, then has(e.f) indicates whether the string f is + // a key in the map (note that f must syntactically be an identifier). + // + name: "has on a map, de-sugared, non-existing field, returns false", + // has marco supports only the dot access syntax. + expression: `has({"foo": 1}.bar)`, + expectedVal: celtypes.False, + }, + { + name: "has on a map, de-sugared, existing field, returns true", + // has marco supports only the dot access syntax. + expression: `has({"foo": 1}.foo)`, + expectedVal: celtypes.True, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, option := NewTypeProviderAndEnvOption(&mockTypeResolverForOptional{ + mockTypeResolver: &mockTypeResolver{}, + }) + env := mustCreateEnvWithOptional(t, option) + ast, issues := env.Compile(tc.expression) + if issues != nil { + if tc.expectedCompileError == "" { + t.Fatalf("unexpected issues during compilation: %v", issues) + } else if !strings.Contains(issues.String(), tc.expectedCompileError) { + t.Fatalf("unexpected compile error, want to contain %q but got %v", tc.expectedCompileError, issues) + } + return + } + program, err := env.Program(ast) + if err != nil { + t.Fatalf("unexpected error while creating program: %v", err) + } + r, _, err := program.Eval(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error during evaluation: %v", err) + } + if equals := tc.expectedVal.Equal(r); equals.Value() != true { + t.Errorf("expected %v but got %v", tc.expectedVal, r) + } + }) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/typeprovider.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/typeprovider.go new file mode 100644 index 00000000000..ef9e1f4ee6d --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/typeprovider.go @@ -0,0 +1,94 @@ +/* +Copyright 2024 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 mutation + +import ( + "k8s.io/apiserver/pkg/cel/mutation/common" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// TypeProvider is a specialized CEL type provider that understands +// the Object type alias that is used to construct an Apply configuration for +// a mutation operation. +type TypeProvider struct { + typeResolver common.TypeResolver + underlyingTypeProvider types.Provider +} + +var _ types.Provider = (*TypeProvider)(nil) + +// EnumValue returns the numeric value of the given enum value name. +// This TypeProvider does not have special handling for EnumValue and thus directly delegate +// to its underlying type provider. +func (p *TypeProvider) EnumValue(enumName string) ref.Val { + return p.underlyingTypeProvider.EnumValue(enumName) +} + +// FindIdent takes a qualified identifier name and returns a ref.ObjectVal if one exists. +// This TypeProvider does not have special handling for FindIdent and thus directly delegate +// to its underlying type provider. +func (p *TypeProvider) FindIdent(identName string) (ref.Val, bool) { + return p.underlyingTypeProvider.FindIdent(identName) +} + +// FindStructType returns the Type give a qualified type name, by looking it up with +// the TypeResolver and translating it to CEL Type. +// If the type is not known to the TypeResolver, the lookup falls back to the underlying +// TypeProvider instead. +func (p *TypeProvider) FindStructType(structType string) (*types.Type, bool) { + t, ok := p.typeResolver.Resolve(structType) + if ok { + return t.CELType(), true + } + return p.underlyingTypeProvider.FindStructType(structType) +} + +// FindStructFieldType returns the field type for a checked type value. +// Returns false if the field could not be found. +func (p *TypeProvider) FindStructFieldType(structType, fieldName string) (*types.FieldType, bool) { + t, ok := p.typeResolver.Resolve(structType) + if ok { + return t.Field(fieldName) + } + return p.underlyingTypeProvider.FindStructFieldType(structType, fieldName) +} + +// NewValue creates a new type value from a qualified name and map of fields. +func (p *TypeProvider) NewValue(structType string, fields map[string]ref.Val) ref.Val { + t, ok := p.typeResolver.Resolve(structType) + if ok { + return t.Val(fields) + } + return p.underlyingTypeProvider.NewValue(structType, fields) +} + +// NewTypeProviderAndEnvOption creates the TypeProvider with a given TypeResolver, +// and also returns the CEL EnvOption to apply it to the env. +func NewTypeProviderAndEnvOption(resolver common.TypeResolver) (*TypeProvider, cel.EnvOption) { + tp := &TypeProvider{typeResolver: resolver} + var envOption cel.EnvOption = func(e *cel.Env) (*cel.Env, error) { + // wrap the existing type provider (acquired from the env) + // and set new type provider for the env. + tp.underlyingTypeProvider = e.CELTypeProvider() + typeProviderOption := cel.CustomTypeProvider(tp) + return typeProviderOption(e) + } + return tp, envOption +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/typeprovider_test.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/typeprovider_test.go new file mode 100644 index 00000000000..8e95d087372 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/typeprovider_test.go @@ -0,0 +1,66 @@ +/* +Copyright 2024 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 mutation + +import ( + "reflect" + "testing" +) + +func TestTypeProvider(t *testing.T) { + for _, tc := range []struct { + name string + expression string + expectedValue any + }{ + { + name: "not an object", + expression: `2 * 31 * 1847`, + expectedValue: int64(114514), // type resolver should not interfere. + }, + { + name: "empty", + expression: "Object{}", + expectedValue: map[string]any{}, + }, + { + name: "Object.spec", + expression: "Object{spec: Object.spec{replicas: 3}}", + expectedValue: map[string]any{"spec": map[string]any{"replicas": int64(3)}}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, option := NewTypeProviderAndEnvOption(&mockTypeResolver{}) + env := mustCreateEnv(t, option) + ast, issues := env.Compile(tc.expression) + if issues != nil { + t.Fatalf("unexpected issues during compilation: %v", issues) + } + program, err := env.Program(ast) + if err != nil { + t.Fatalf("unexpected error while creating program: %v", err) + } + r, _, err := program.Eval(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error during evaluation: %v", err) + } + if v := r.Value(); !reflect.DeepEqual(tc.expectedValue, v) { + t.Errorf("expected %v but got %v", tc.expectedValue, v) + } + }) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/fieldtype.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/fieldtype.go new file mode 100644 index 00000000000..f99ac516d03 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/fieldtype.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 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 unstructured + +import ( + "fmt" + + "github.com/google/cel-go/common/types" +) + +// NewFieldType creates a field by its field name. +// This version of FieldType is unstructured and has DynType as its type. +func NewFieldType(name string) *types.FieldType { + return &types.FieldType{ + // for unstructured, we do not check for its type, + // use DynType for all fields. + Type: types.DynType, + IsSet: func(target any) bool { + // for an unstructured object, we allow any field to be considered set. + return true + }, + GetFrom: func(target any) (any, error) { + if m, ok := target.(map[string]any); ok { + return m[name], nil + } + return nil, fmt.Errorf("cannot get field %q", name) + }, + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeref.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeref.go new file mode 100644 index 00000000000..32ab8d28274 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeref.go @@ -0,0 +1,66 @@ +/* +Copyright 2024 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 unstructured + +import ( + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + + "k8s.io/apiserver/pkg/cel/mutation/common" +) + +// TypeRef is the implementation of TypeRef for an unstructured object. +// This is especially usefully when the schema is not known or available. +type TypeRef struct { + celObjectType *types.Type + celTypeType *types.Type +} + +func (r *TypeRef) HasTrait(trait int) bool { + return common.ObjectTraits|trait != 0 +} + +// TypeName returns the name of this TypeRef. +func (r *TypeRef) TypeName() string { + return r.celObjectType.TypeName() +} + +// Val returns an instance given the fields. +func (r *TypeRef) Val(fields map[string]ref.Val) ref.Val { + return common.NewObjectVal(r, fields) +} + +// CELType returns the type. The returned type is of TypeType type. +func (r *TypeRef) CELType() *types.Type { + return r.celTypeType +} + +// Field looks up the field by name. +// This is the unstructured version that allows any name as the field name. +// The returned field is of DynType type. +func (r *TypeRef) Field(name string) (*types.FieldType, bool) { + return NewFieldType(name), true +} + +// NewTypeRef creates a TypeRef by the given field name. +func NewTypeRef(name string) *TypeRef { + objectType := types.NewObjectType(name, common.ObjectTraits) + return &TypeRef{ + celObjectType: objectType, + celTypeType: types.NewTypeTypeWithParam(objectType), + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeresolver.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeresolver.go new file mode 100644 index 00000000000..2182b931676 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeresolver.go @@ -0,0 +1,39 @@ +/* +Copyright 2024 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 unstructured + +import ( + "strings" + + "k8s.io/apiserver/pkg/cel/mutation/common" +) + +const object = common.RootTypeReferenceName + +type TypeResolver struct { +} + +// Resolve resolves the TypeRef for the given type name +// that starts with "Object". +// This is the unstructured version, which means the +// returned TypeRef does not refer to the schema. +func (r *TypeResolver) Resolve(name string) (common.TypeRef, bool) { + if !strings.HasPrefix(name, object) { + return nil, false + } + return NewTypeRef(name), true +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeresolver_test.go b/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeresolver_test.go new file mode 100644 index 00000000000..b46fc273eaf --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/mutation/unstructured/typeresolver_test.go @@ -0,0 +1,145 @@ +/* +Copyright 2024 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 unstructured + +import ( + "reflect" + "testing" + + "github.com/google/cel-go/cel" + + "k8s.io/apimachinery/pkg/util/version" + "k8s.io/apiserver/pkg/cel/environment" + "k8s.io/apiserver/pkg/cel/mutation" +) + +func TestTypeProvider(t *testing.T) { + for _, tc := range []struct { + name string + expression string + expectedValue any + }{ + { + name: "not an object", + expression: `string(114514)`, + expectedValue: "114514", + }, + { + name: "empty", + expression: "Object{}", + expectedValue: map[string]any{}, + }, + { + name: "Object.spec", + expression: "Object{spec: Object.spec{replicas: 3}}", + expectedValue: map[string]any{ + "spec": map[string]any{ + // an integer maps to int64 + "replicas": int64(3), + }, + }, + }, + { + // list literal does not require new path code of the type provider + // comparing to the object literal. + // This test case serves as a note of "supported syntax" + name: "Object.spec.template.containers", + expression: `Object{ + spec: Object.spec{ + template: Object.spec.template{ + containers: [ + Object.spec.template.containers.item{ + name: "nginx", + image: "nginx", + args: ["-g"] + } + ] + } + } + }`, + expectedValue: map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "containers": []any{ + map[string]any{ + "name": "nginx", + "image": "nginx", + "args": []any{"-g"}, + }, + }, + }, + }, + }, + }, + { + name: "list of ints", + expression: `Object{ + intList: [1, 2, 3] + }`, + expectedValue: map[string]any{ + "intList": []any{int64(1), int64(2), int64(3)}, + }, + }, + { + name: "field access", + expression: `Object{ + intList: [1, 2, 3] + }.intList.sum()`, + expectedValue: int64(6), + }, + { + name: "equality check", + expression: "Object{spec: Object.spec{replicas: 3}} == Object{spec: Object.spec{replicas: 1 + 2}}", + expectedValue: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, option := mutation.NewTypeProviderAndEnvOption(&TypeResolver{}) + env := mustCreateEnv(t, option) + ast, issues := env.Compile(tc.expression) + if issues != nil { + t.Fatalf("unexpected issues during compilation: %v", issues) + } + program, err := env.Program(ast) + if err != nil { + t.Fatalf("unexpected error while creating program: %v", err) + } + r, _, err := program.Eval(map[string]any{}) + if err != nil { + t.Fatalf("unexpected error during evaluation: %v", err) + } + if v := r.Value(); !reflect.DeepEqual(v, tc.expectedValue) { + t.Errorf("expected %v but got %v", tc.expectedValue, v) + } + }) + } +} +func mustCreateEnv(t testing.TB, envOptions ...cel.EnvOption) *cel.Env { + envSet, err := environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()). + Extend(environment.VersionedOptions{ + IntroducedVersion: version.MajorMinor(1, 30), + EnvOptions: envOptions, + }) + if err != nil { + t.Fatalf("fail to create env set: %v", err) + } + env, err := envSet.Env(environment.StoredExpressions) + if err != nil { + t.Fatalf("fail to setup env: %v", env) + } + return env +}