add int. test for CEL type resolution.
This commit is contained in:
parent
43ef87a268
commit
5c6d8a939c
516
test/integration/apiserver/cel/typeresolution_test.go
Normal file
516
test/integration/apiserver/cel/typeresolution_test.go
Normal file
@ -0,0 +1,516 @@
|
||||
/*
|
||||
Copyright 2022 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/interpreter"
|
||||
|
||||
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
apiv1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
nodev1 "k8s.io/api/node/v1"
|
||||
storagev1 "k8s.io/api/storage/v1"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
extclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
commoncel "k8s.io/apiserver/pkg/cel"
|
||||
"k8s.io/apiserver/pkg/cel/library"
|
||||
celopenapi "k8s.io/apiserver/pkg/cel/openapi"
|
||||
"k8s.io/apiserver/pkg/cel/openapi/resolver"
|
||||
k8sscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
|
||||
corev1 "k8s.io/kubernetes/pkg/apis/core/v1"
|
||||
"k8s.io/kubernetes/pkg/generated/openapi"
|
||||
"k8s.io/kubernetes/test/integration/framework"
|
||||
"k8s.io/utils/pointer"
|
||||
)
|
||||
|
||||
func TestTypeResolver(t *testing.T) {
|
||||
server, err := apiservertesting.StartTestServer(t, nil, nil, framework.SharedEtcd())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.TearDownFn()
|
||||
|
||||
config := server.ClientConfig
|
||||
|
||||
client, err := extclientset.NewForConfig(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
crd, err := installCRD(client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func(crd *apiextensionsv1.CustomResourceDefinition) {
|
||||
err := client.ApiextensionsV1().CustomResourceDefinitions().Delete(context.Background(), crd.Name, metav1.DeleteOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}(crd)
|
||||
discoveryResolver := &resolver.ClientDiscoveryResolver{Discovery: client.Discovery()}
|
||||
definitionsResolver := resolver.NewDefinitionsSchemaResolver(k8sscheme.Scheme, openapi.GetOpenAPIDefinitions)
|
||||
// wait until the CRD schema is published at the OpenAPI v3 endpoint
|
||||
err = wait.PollImmediate(time.Second, time.Minute, func() (done bool, err error) {
|
||||
p, err := client.OpenAPIV3().Paths()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, ok := p["apis/apis.example.com/v1beta1"]; ok {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("timeout wait for CRD schema publication: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
obj runtime.Object
|
||||
expression string
|
||||
expectResolutionErr bool
|
||||
expectCompileErr bool
|
||||
expectEvalErr bool
|
||||
expectedResult any
|
||||
resolvers []resolver.SchemaResolver
|
||||
}{
|
||||
{
|
||||
name: "unknown type",
|
||||
obj: &unstructured.Unstructured{Object: map[string]any{
|
||||
"kind": "Bad",
|
||||
"apiVersion": "bad.example.com/v1",
|
||||
}},
|
||||
expectResolutionErr: true,
|
||||
resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
|
||||
},
|
||||
{
|
||||
name: "deployment",
|
||||
obj: sampleReplicatedDeployment(),
|
||||
expression: "self.spec.replicas > 1",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: false,
|
||||
expectEvalErr: false,
|
||||
resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
|
||||
|
||||
// expect a boolean, which is `true`.
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "missing field",
|
||||
obj: sampleReplicatedDeployment(),
|
||||
expression: "self.spec.missing > 1",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: true,
|
||||
resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
|
||||
},
|
||||
{
|
||||
name: "mistyped expression",
|
||||
obj: sampleReplicatedDeployment(),
|
||||
expression: "self.spec.replicas == '1'",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: true,
|
||||
resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
|
||||
},
|
||||
{
|
||||
name: "crd valid",
|
||||
obj: &unstructured.Unstructured{Object: map[string]any{
|
||||
"kind": "CronTab",
|
||||
"apiVersion": "apis.example.com/v1beta1",
|
||||
"spec": map[string]any{
|
||||
"cronSpec": "* * * * *",
|
||||
"image": "foo-image",
|
||||
"replicas": 2,
|
||||
},
|
||||
}},
|
||||
expression: "self.spec.replicas > 1",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: false,
|
||||
expectEvalErr: false,
|
||||
resolvers: []resolver.SchemaResolver{discoveryResolver},
|
||||
|
||||
// expect a boolean, which is `true`.
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "crd missing field",
|
||||
obj: &unstructured.Unstructured{Object: map[string]any{
|
||||
"kind": "CronTab",
|
||||
"apiVersion": "apis.example.com/v1beta1",
|
||||
"spec": map[string]any{
|
||||
"cronSpec": "* * * * *",
|
||||
"image": "foo-image",
|
||||
"replicas": 2,
|
||||
},
|
||||
}},
|
||||
expression: "self.spec.missing > 1",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: true,
|
||||
resolvers: []resolver.SchemaResolver{discoveryResolver},
|
||||
},
|
||||
{
|
||||
name: "crd mistyped",
|
||||
obj: &unstructured.Unstructured{Object: map[string]any{
|
||||
"kind": "CronTab",
|
||||
"apiVersion": "apis.example.com/v1beta1",
|
||||
"spec": map[string]any{
|
||||
"cronSpec": "* * * * *",
|
||||
"image": "foo-image",
|
||||
"replicas": 2,
|
||||
},
|
||||
}},
|
||||
expression: "self.spec.replica == '1'",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: true,
|
||||
resolvers: []resolver.SchemaResolver{discoveryResolver},
|
||||
},
|
||||
{
|
||||
name: "items population",
|
||||
obj: sampleReplicatedDeployment(),
|
||||
// `containers` is an array whose items are of `Container` type
|
||||
// `ports` is an array of `ContainerPort`
|
||||
expression: "size(self.spec.template.spec.containers) > 0 &&" +
|
||||
"self.spec.template.spec.containers.all(c, c.ports.all(p, p.containerPort < 1024))",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: false,
|
||||
expectEvalErr: false,
|
||||
expectedResult: true,
|
||||
resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
|
||||
},
|
||||
{
|
||||
name: "int-or-string int",
|
||||
obj: &appsv1.Deployment{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Deployment",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Strategy: appsv1.DeploymentStrategy{
|
||||
Type: appsv1.RollingUpdateDeploymentStrategyType,
|
||||
RollingUpdate: &appsv1.RollingUpdateDeployment{
|
||||
MaxSurge: &intstr.IntOrString{Type: intstr.Int, IntVal: 5},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expression: "has(self.spec.strategy.rollingUpdate) &&" +
|
||||
"type(self.spec.strategy.rollingUpdate.maxSurge) == int &&" +
|
||||
"self.spec.strategy.rollingUpdate.maxSurge > 1",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: false,
|
||||
expectEvalErr: false,
|
||||
expectedResult: true,
|
||||
resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
|
||||
},
|
||||
{
|
||||
name: "int-or-string string",
|
||||
obj: &appsv1.Deployment{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Deployment",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Strategy: appsv1.DeploymentStrategy{
|
||||
Type: appsv1.RollingUpdateDeploymentStrategyType,
|
||||
RollingUpdate: &appsv1.RollingUpdateDeployment{
|
||||
MaxSurge: &intstr.IntOrString{Type: intstr.String, StrVal: "10%"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expression: "has(self.spec.strategy.rollingUpdate) &&" +
|
||||
"type(self.spec.strategy.rollingUpdate.maxSurge) == string &&" +
|
||||
"self.spec.strategy.rollingUpdate.maxSurge == '10%'",
|
||||
expectResolutionErr: false,
|
||||
expectCompileErr: false,
|
||||
expectEvalErr: false,
|
||||
expectedResult: true,
|
||||
resolvers: []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gvk := tc.obj.GetObjectKind().GroupVersionKind()
|
||||
var s *spec.Schema
|
||||
for _, r := range tc.resolvers {
|
||||
var err error
|
||||
s, err = r.ResolveSchema(gvk)
|
||||
if err != nil {
|
||||
if tc.expectResolutionErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("cannot resolve type: %v", err)
|
||||
}
|
||||
if tc.expectResolutionErr {
|
||||
t.Fatalf("expected resulution error but got none")
|
||||
}
|
||||
}
|
||||
program, err := simpleCompileCEL(s, tc.expression)
|
||||
if err != nil {
|
||||
if tc.expectCompileErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("cannot eval: %v", err)
|
||||
}
|
||||
if tc.expectCompileErr {
|
||||
t.Fatalf("expected compilation error but got none")
|
||||
}
|
||||
unstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.obj)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ret, _, err := program.Eval(&simpleActivation{self: celopenapi.UnstructuredToVal(unstructured, s)})
|
||||
if err != nil {
|
||||
if tc.expectEvalErr {
|
||||
return
|
||||
}
|
||||
t.Fatalf("cannot eval: %v", err)
|
||||
}
|
||||
if tc.expectEvalErr {
|
||||
t.Fatalf("expected eval error but got none")
|
||||
}
|
||||
if !reflect.DeepEqual(ret.Value(), tc.expectedResult) {
|
||||
t.Errorf("wrong result, expected %q but got %q", tc.expectedResult, ret)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestBuiltinResolution asserts that all resolver implementations should
|
||||
// resolve Kubernetes built-in types without error.
|
||||
func TestBuiltinResolution(t *testing.T) {
|
||||
// before all, setup server and client
|
||||
server, err := apiservertesting.StartTestServer(t, nil, nil, framework.SharedEtcd())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.TearDownFn()
|
||||
|
||||
config := server.ClientConfig
|
||||
|
||||
client, err := extclientset.NewForConfig(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolver resolver.SchemaResolver
|
||||
scheme *runtime.Scheme
|
||||
}{
|
||||
{
|
||||
name: "definitions",
|
||||
resolver: resolver.NewDefinitionsSchemaResolver(k8sscheme.Scheme, openapi.GetOpenAPIDefinitions),
|
||||
scheme: buildTestScheme(),
|
||||
},
|
||||
{
|
||||
name: "discovery",
|
||||
resolver: &resolver.ClientDiscoveryResolver{Discovery: client.Discovery()},
|
||||
scheme: buildTestScheme(),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for gvk := range tc.scheme.AllKnownTypes() {
|
||||
// skip aliases to metav1
|
||||
if gvk.Kind == "APIGroup" || gvk.Kind == "APIGroupList" || gvk.Kind == "APIVersions" ||
|
||||
strings.HasSuffix(gvk.Kind, "Options") || strings.HasSuffix(gvk.Kind, "Event") {
|
||||
continue
|
||||
}
|
||||
// skip private, reference, and alias types that cannot appear in the wild
|
||||
if gvk.Kind == "SerializedReference" || gvk.Kind == "List" || gvk.Kind == "RangeAllocation" || gvk.Kind == "PodStatusResult" {
|
||||
continue
|
||||
}
|
||||
// skip internal types
|
||||
if gvk.Version == "__internal" {
|
||||
continue
|
||||
}
|
||||
_, err = tc.resolver.ResolveSchema(gvk)
|
||||
if err != nil {
|
||||
t.Errorf("resolver %q cannot resolve %v", tc.name, gvk)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// simpleCompileCEL compiles the CEL expression against the schema
|
||||
// with the practical defaults.
|
||||
// `self` is defined as the object being evaluated against.
|
||||
func simpleCompileCEL(schema *spec.Schema, expression string) (cel.Program, error) {
|
||||
var opts []cel.EnvOption
|
||||
opts = append(opts, cel.HomogeneousAggregateLiterals())
|
||||
opts = append(opts, cel.EagerlyValidateDeclarations(true), cel.DefaultUTCTimeZone(true))
|
||||
opts = append(opts, library.ExtensionLibs...)
|
||||
env, err := cel.NewEnv(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg := commoncel.NewRegistry(env)
|
||||
declType := celopenapi.SchemaDeclType(schema, true)
|
||||
rt, err := commoncel.NewRuleTypes("selfType", declType, reg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts, err = rt.EnvOptions(env.TypeProvider())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootType, _ := rt.FindDeclType("selfType")
|
||||
opts = append(opts, cel.Variable("self", rootType.CelType()))
|
||||
env, err = env.Extend(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ast, issues := env.Compile(expression)
|
||||
if issues != nil {
|
||||
return nil, issues.Err()
|
||||
}
|
||||
return env.Program(ast)
|
||||
}
|
||||
|
||||
// sampleReplicatedDeployment returns a sample Deployment with 2 replicas.
|
||||
// The object is not inlined because the schema of Deployment is well-known
|
||||
// and thus requires no reference when reading the test cases.
|
||||
func sampleReplicatedDeployment() *appsv1.Deployment {
|
||||
return &appsv1.Deployment{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Deployment",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "demo-deployment",
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: pointer.Int32(2),
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": "demo",
|
||||
},
|
||||
},
|
||||
Template: apiv1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": "demo",
|
||||
},
|
||||
},
|
||||
Spec: apiv1.PodSpec{
|
||||
Containers: []apiv1.Container{
|
||||
{
|
||||
Name: "web",
|
||||
Image: "nginx",
|
||||
Ports: []apiv1.ContainerPort{
|
||||
{
|
||||
Name: "http",
|
||||
Protocol: apiv1.ProtocolTCP,
|
||||
ContainerPort: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func installCRD(apiExtensionClient extclientset.Interface) (*apiextensionsv1.CustomResourceDefinition, error) {
|
||||
// CRD borrowed from https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/
|
||||
crd := &apiextensionsv1.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "crontabs.apis.example.com",
|
||||
},
|
||||
Spec: apiextensionsv1.CustomResourceDefinitionSpec{
|
||||
Group: "apis.example.com",
|
||||
Scope: apiextensionsv1.NamespaceScoped,
|
||||
Names: apiextensionsv1.CustomResourceDefinitionNames{
|
||||
Plural: "crontabs",
|
||||
Singular: "crontab",
|
||||
Kind: "CronTab",
|
||||
ListKind: "CronTabList",
|
||||
},
|
||||
Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
|
||||
{
|
||||
Name: "v1beta1",
|
||||
Served: true,
|
||||
Storage: true,
|
||||
Schema: &apiextensionsv1.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
|
||||
XPreserveUnknownFields: pointer.Bool(true),
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensionsv1.JSONSchemaProps{
|
||||
"spec": {
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensionsv1.JSONSchemaProps{
|
||||
"cronSpec": {Type: "string"},
|
||||
"image": {Type: "string"},
|
||||
"replicas": {Type: "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return apiExtensionClient.ApiextensionsV1().
|
||||
CustomResourceDefinitions().Create(context.Background(), crd, metav1.CreateOptions{})
|
||||
}
|
||||
|
||||
type simpleActivation struct {
|
||||
self any
|
||||
}
|
||||
|
||||
func (a *simpleActivation) ResolveName(name string) (interface{}, bool) {
|
||||
switch name {
|
||||
case "self":
|
||||
return a.self, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *simpleActivation) Parent() interpreter.Activation {
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildTestScheme() *runtime.Scheme {
|
||||
// hand-picked schemes that the test API server serves
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = appsv1.AddToScheme(scheme)
|
||||
_ = admissionregistrationv1.AddToScheme(scheme)
|
||||
_ = networkingv1.AddToScheme(scheme)
|
||||
_ = nodev1.AddToScheme(scheme)
|
||||
_ = storagev1.AddToScheme(scheme)
|
||||
return scheme
|
||||
}
|
Loading…
Reference in New Issue
Block a user