k8s.io/kubernetes@v1.29.3/test/integration/apiserver/cel/typeresolution_test.go (about)

     1  /*
     2  Copyright 2022 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package cel
    18  
    19  import (
    20  	"context"
    21  	"reflect"
    22  	"strings"
    23  	"testing"
    24  	"time"
    25  
    26  	"github.com/google/cel-go/cel"
    27  	"github.com/google/cel-go/interpreter"
    28  
    29  	"k8s.io/apiserver/pkg/cel/environment"
    30  
    31  	admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
    32  	appsv1 "k8s.io/api/apps/v1"
    33  	apiv1 "k8s.io/api/core/v1"
    34  	networkingv1 "k8s.io/api/networking/v1"
    35  	nodev1 "k8s.io/api/node/v1"
    36  	storagev1 "k8s.io/api/storage/v1"
    37  	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
    38  	extclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
    39  	apiextensionsscheme "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/scheme"
    40  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    41  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    42  	"k8s.io/apimachinery/pkg/runtime"
    43  	"k8s.io/apimachinery/pkg/util/intstr"
    44  	"k8s.io/apimachinery/pkg/util/wait"
    45  	commoncel "k8s.io/apiserver/pkg/cel"
    46  	celopenapi "k8s.io/apiserver/pkg/cel/openapi"
    47  	"k8s.io/apiserver/pkg/cel/openapi/resolver"
    48  	k8sscheme "k8s.io/client-go/kubernetes/scheme"
    49  	"k8s.io/kube-openapi/pkg/validation/spec"
    50  	"k8s.io/utils/pointer"
    51  
    52  	apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
    53  	corev1 "k8s.io/kubernetes/pkg/apis/core/v1"
    54  	"k8s.io/kubernetes/pkg/generated/openapi"
    55  	"k8s.io/kubernetes/test/integration/framework"
    56  )
    57  
    58  func TestTypeResolver(t *testing.T) {
    59  	server, err := apiservertesting.StartTestServer(t, nil, nil, framework.SharedEtcd())
    60  	if err != nil {
    61  		t.Fatal(err)
    62  	}
    63  	defer server.TearDownFn()
    64  
    65  	config := server.ClientConfig
    66  
    67  	client, err := extclientset.NewForConfig(config)
    68  	if err != nil {
    69  		t.Fatal(err)
    70  	}
    71  
    72  	crd, err := installCRD(client)
    73  	if err != nil {
    74  		t.Fatal(err)
    75  	}
    76  	defer func(crd *apiextensionsv1.CustomResourceDefinition) {
    77  		err := client.ApiextensionsV1().CustomResourceDefinitions().Delete(context.Background(), crd.Name, metav1.DeleteOptions{})
    78  		if err != nil {
    79  			t.Fatal(err)
    80  		}
    81  	}(crd)
    82  	discoveryResolver := &resolver.ClientDiscoveryResolver{Discovery: client.Discovery()}
    83  	definitionsResolver := resolver.NewDefinitionsSchemaResolver(openapi.GetOpenAPIDefinitions, k8sscheme.Scheme, apiextensionsscheme.Scheme)
    84  	// wait until the CRD schema is published at the OpenAPI v3 endpoint
    85  	err = wait.PollImmediate(time.Second, time.Minute, func() (done bool, err error) {
    86  		p, err := client.OpenAPIV3().Paths()
    87  		if err != nil {
    88  			return
    89  		}
    90  		if _, ok := p["apis/apis.example.com/v1beta1"]; ok {
    91  			return true, nil
    92  		}
    93  		return false, nil
    94  	})
    95  	if err != nil {
    96  		t.Fatalf("timeout wait for CRD schema publication: %v", err)
    97  	}
    98  
    99  	for _, tc := range []struct {
   100  		name                string
   101  		obj                 runtime.Object
   102  		expression          string
   103  		expectResolutionErr bool
   104  		expectCompileErr    bool
   105  		expectEvalErr       bool
   106  		expectedResult      any
   107  		resolvers           []resolver.SchemaResolver
   108  	}{
   109  		{
   110  			name: "unknown type",
   111  			obj: &unstructured.Unstructured{Object: map[string]any{
   112  				"kind":       "Bad",
   113  				"apiVersion": "bad.example.com/v1",
   114  			}},
   115  			expectResolutionErr: true,
   116  			resolvers:           []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
   117  		},
   118  		{
   119  			name:                "deployment",
   120  			obj:                 sampleReplicatedDeployment(),
   121  			expression:          "self.spec.replicas > 1",
   122  			expectResolutionErr: false,
   123  			expectCompileErr:    false,
   124  			expectEvalErr:       false,
   125  			resolvers:           []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
   126  
   127  			// expect a boolean, which is `true`.
   128  			expectedResult: true,
   129  		},
   130  		{
   131  			name:                "missing field",
   132  			obj:                 sampleReplicatedDeployment(),
   133  			expression:          "self.spec.missing > 1",
   134  			expectResolutionErr: false,
   135  			expectCompileErr:    true,
   136  			resolvers:           []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
   137  		},
   138  		{
   139  			name:                "mistyped expression",
   140  			obj:                 sampleReplicatedDeployment(),
   141  			expression:          "self.spec.replicas == '1'",
   142  			expectResolutionErr: false,
   143  			expectCompileErr:    true,
   144  			resolvers:           []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
   145  		},
   146  		{
   147  			name: "crd valid",
   148  			obj: &unstructured.Unstructured{Object: map[string]any{
   149  				"kind":       "CronTab",
   150  				"apiVersion": "apis.example.com/v1beta1",
   151  				"spec": map[string]any{
   152  					"cronSpec": "* * * * *",
   153  					"image":    "foo-image",
   154  					"replicas": 2,
   155  				},
   156  			}},
   157  			expression:          "self.spec.replicas > 1",
   158  			expectResolutionErr: false,
   159  			expectCompileErr:    false,
   160  			expectEvalErr:       false,
   161  			resolvers:           []resolver.SchemaResolver{discoveryResolver},
   162  
   163  			// expect a boolean, which is `true`.
   164  			expectedResult: true,
   165  		},
   166  		{
   167  			name: "crd missing field",
   168  			obj: &unstructured.Unstructured{Object: map[string]any{
   169  				"kind":       "CronTab",
   170  				"apiVersion": "apis.example.com/v1beta1",
   171  				"spec": map[string]any{
   172  					"cronSpec": "* * * * *",
   173  					"image":    "foo-image",
   174  					"replicas": 2,
   175  				},
   176  			}},
   177  			expression:          "self.spec.missing > 1",
   178  			expectResolutionErr: false,
   179  			expectCompileErr:    true,
   180  			resolvers:           []resolver.SchemaResolver{discoveryResolver},
   181  		},
   182  		{
   183  			name: "crd mistyped",
   184  			obj: &unstructured.Unstructured{Object: map[string]any{
   185  				"kind":       "CronTab",
   186  				"apiVersion": "apis.example.com/v1beta1",
   187  				"spec": map[string]any{
   188  					"cronSpec": "* * * * *",
   189  					"image":    "foo-image",
   190  					"replicas": 2,
   191  				},
   192  			}},
   193  			expression:          "self.spec.replica == '1'",
   194  			expectResolutionErr: false,
   195  			expectCompileErr:    true,
   196  			resolvers:           []resolver.SchemaResolver{discoveryResolver},
   197  		},
   198  		{
   199  			name: "items population",
   200  			obj:  sampleReplicatedDeployment(),
   201  			// `containers` is an array whose items are of `Container` type
   202  			// `ports` is an array of `ContainerPort`
   203  			expression: "size(self.spec.template.spec.containers) > 0 &&" +
   204  				"self.spec.template.spec.containers.all(c, c.ports.all(p, p.containerPort < 1024))",
   205  			expectResolutionErr: false,
   206  			expectCompileErr:    false,
   207  			expectEvalErr:       false,
   208  			expectedResult:      true,
   209  			resolvers:           []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
   210  		},
   211  		{
   212  			name: "int-or-string int",
   213  			obj: &appsv1.Deployment{
   214  				TypeMeta: metav1.TypeMeta{
   215  					Kind:       "Deployment",
   216  					APIVersion: "apps/v1",
   217  				},
   218  				Spec: appsv1.DeploymentSpec{
   219  					Strategy: appsv1.DeploymentStrategy{
   220  						Type: appsv1.RollingUpdateDeploymentStrategyType,
   221  						RollingUpdate: &appsv1.RollingUpdateDeployment{
   222  							MaxSurge: &intstr.IntOrString{Type: intstr.Int, IntVal: 5},
   223  						},
   224  					},
   225  				},
   226  			},
   227  			expression: "has(self.spec.strategy.rollingUpdate) &&" +
   228  				"type(self.spec.strategy.rollingUpdate.maxSurge) == int &&" +
   229  				"self.spec.strategy.rollingUpdate.maxSurge > 1",
   230  			expectResolutionErr: false,
   231  			expectCompileErr:    false,
   232  			expectEvalErr:       false,
   233  			expectedResult:      true,
   234  			resolvers:           []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
   235  		},
   236  		{
   237  			name: "int-or-string string",
   238  			obj: &appsv1.Deployment{
   239  				TypeMeta: metav1.TypeMeta{
   240  					Kind:       "Deployment",
   241  					APIVersion: "apps/v1",
   242  				},
   243  				Spec: appsv1.DeploymentSpec{
   244  					Strategy: appsv1.DeploymentStrategy{
   245  						Type: appsv1.RollingUpdateDeploymentStrategyType,
   246  						RollingUpdate: &appsv1.RollingUpdateDeployment{
   247  							MaxSurge: &intstr.IntOrString{Type: intstr.String, StrVal: "10%"},
   248  						},
   249  					},
   250  				},
   251  			},
   252  			expression: "has(self.spec.strategy.rollingUpdate) &&" +
   253  				"type(self.spec.strategy.rollingUpdate.maxSurge) == string &&" +
   254  				"self.spec.strategy.rollingUpdate.maxSurge == '10%'",
   255  			expectResolutionErr: false,
   256  			expectCompileErr:    false,
   257  			expectEvalErr:       false,
   258  			expectedResult:      true,
   259  			resolvers:           []resolver.SchemaResolver{definitionsResolver, discoveryResolver},
   260  		},
   261  	} {
   262  		t.Run(tc.name, func(t *testing.T) {
   263  			gvk := tc.obj.GetObjectKind().GroupVersionKind()
   264  			var s *spec.Schema
   265  			for _, r := range tc.resolvers {
   266  				var err error
   267  				s, err = r.ResolveSchema(gvk)
   268  				if err != nil {
   269  					if tc.expectResolutionErr {
   270  						return
   271  					}
   272  					t.Fatalf("cannot resolve type: %v", err)
   273  				}
   274  				if tc.expectResolutionErr {
   275  					t.Fatalf("expected resulution error but got none")
   276  				}
   277  			}
   278  			program, err := simpleCompileCEL(s, tc.expression)
   279  			if err != nil {
   280  				if tc.expectCompileErr {
   281  					return
   282  				}
   283  				t.Fatalf("cannot eval: %v", err)
   284  			}
   285  			if tc.expectCompileErr {
   286  				t.Fatalf("expected compilation error but got none")
   287  			}
   288  			unstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.obj)
   289  			if err != nil {
   290  				t.Fatal(err)
   291  			}
   292  			ret, _, err := program.Eval(&simpleActivation{self: celopenapi.UnstructuredToVal(unstructured, s)})
   293  			if err != nil {
   294  				if tc.expectEvalErr {
   295  					return
   296  				}
   297  				t.Fatalf("cannot eval: %v", err)
   298  			}
   299  			if tc.expectEvalErr {
   300  				t.Fatalf("expected eval error but got none")
   301  			}
   302  			if !reflect.DeepEqual(ret.Value(), tc.expectedResult) {
   303  				t.Errorf("wrong result, expected %q but got %q", tc.expectedResult, ret)
   304  			}
   305  		})
   306  	}
   307  
   308  }
   309  
   310  // TestBuiltinResolution asserts that all resolver implementations should
   311  // resolve Kubernetes built-in types without error.
   312  func TestBuiltinResolution(t *testing.T) {
   313  	// before all, setup server and client
   314  	server, err := apiservertesting.StartTestServer(t, nil, nil, framework.SharedEtcd())
   315  	if err != nil {
   316  		t.Fatal(err)
   317  	}
   318  	defer server.TearDownFn()
   319  
   320  	config := server.ClientConfig
   321  
   322  	client, err := extclientset.NewForConfig(config)
   323  	if err != nil {
   324  		t.Fatal(err)
   325  	}
   326  
   327  	for _, tc := range []struct {
   328  		name     string
   329  		resolver resolver.SchemaResolver
   330  		scheme   *runtime.Scheme
   331  	}{
   332  		{
   333  			name:     "definitions",
   334  			resolver: resolver.NewDefinitionsSchemaResolver(openapi.GetOpenAPIDefinitions, k8sscheme.Scheme, apiextensionsscheme.Scheme),
   335  			scheme:   buildTestScheme(),
   336  		},
   337  		{
   338  			name:     "discovery",
   339  			resolver: &resolver.ClientDiscoveryResolver{Discovery: client.Discovery()},
   340  			scheme:   buildTestScheme(),
   341  		},
   342  	} {
   343  		t.Run(tc.name, func(t *testing.T) {
   344  			for gvk := range tc.scheme.AllKnownTypes() {
   345  				// skip aliases to metav1
   346  				if gvk.Kind == "APIGroup" || gvk.Kind == "APIGroupList" || gvk.Kind == "APIVersions" ||
   347  					strings.HasSuffix(gvk.Kind, "Options") || strings.HasSuffix(gvk.Kind, "Event") {
   348  					continue
   349  				}
   350  				// skip private, reference, and alias types that cannot appear in the wild
   351  				if gvk.Kind == "SerializedReference" || gvk.Kind == "List" || gvk.Kind == "RangeAllocation" || gvk.Kind == "PodStatusResult" {
   352  					continue
   353  				}
   354  				// skip internal types
   355  				if gvk.Version == "__internal" {
   356  					continue
   357  				}
   358  				// apiextensions.k8s.io/v1beta1 not published
   359  				if tc.name == "discovery" && gvk.Group == "apiextensions.k8s.io" && gvk.Version == "v1beta1" {
   360  					continue
   361  				}
   362  				// apiextensions.k8s.io ConversionReview not published
   363  				if tc.name == "discovery" && gvk.Group == "apiextensions.k8s.io" && gvk.Kind == "ConversionReview" {
   364  					continue
   365  				}
   366  				_, err = tc.resolver.ResolveSchema(gvk)
   367  				if err != nil {
   368  					t.Errorf("resolver %q cannot resolve %v", tc.name, gvk)
   369  				}
   370  			}
   371  		})
   372  	}
   373  }
   374  
   375  // simpleCompileCEL compiles the CEL expression against the schema
   376  // with the practical defaults.
   377  // `self` is defined as the object being evaluated against.
   378  func simpleCompileCEL(schema *spec.Schema, expression string) (cel.Program, error) {
   379  	env, err := environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion()).Env(environment.NewExpressions)
   380  	if err != nil {
   381  		return nil, err
   382  	}
   383  	declType := celopenapi.SchemaDeclType(schema, true).MaybeAssignTypeName("selfType")
   384  	rt := commoncel.NewDeclTypeProvider(declType)
   385  	opts, err := rt.EnvOptions(env.TypeProvider())
   386  	if err != nil {
   387  		return nil, err
   388  	}
   389  	rootType, _ := rt.FindDeclType("selfType")
   390  	opts = append(opts, cel.Variable("self", rootType.CelType()))
   391  	env, err = env.Extend(opts...)
   392  	if err != nil {
   393  		return nil, err
   394  	}
   395  	ast, issues := env.Compile(expression)
   396  	if issues != nil {
   397  		return nil, issues.Err()
   398  	}
   399  	return env.Program(ast)
   400  }
   401  
   402  // sampleReplicatedDeployment returns a sample Deployment with 2 replicas.
   403  // The object is not inlined because the schema of Deployment is well-known
   404  // and thus requires no reference when reading the test cases.
   405  func sampleReplicatedDeployment() *appsv1.Deployment {
   406  	return &appsv1.Deployment{
   407  		TypeMeta: metav1.TypeMeta{
   408  			Kind:       "Deployment",
   409  			APIVersion: "apps/v1",
   410  		},
   411  		ObjectMeta: metav1.ObjectMeta{
   412  			Name: "demo-deployment",
   413  		},
   414  		Spec: appsv1.DeploymentSpec{
   415  			Replicas: pointer.Int32(2),
   416  			Selector: &metav1.LabelSelector{
   417  				MatchLabels: map[string]string{
   418  					"app": "demo",
   419  				},
   420  			},
   421  			Template: apiv1.PodTemplateSpec{
   422  				ObjectMeta: metav1.ObjectMeta{
   423  					Labels: map[string]string{
   424  						"app": "demo",
   425  					},
   426  				},
   427  				Spec: apiv1.PodSpec{
   428  					Containers: []apiv1.Container{
   429  						{
   430  							Name:  "web",
   431  							Image: "nginx",
   432  							Ports: []apiv1.ContainerPort{
   433  								{
   434  									Name:          "http",
   435  									Protocol:      apiv1.ProtocolTCP,
   436  									ContainerPort: 80,
   437  								},
   438  							},
   439  						},
   440  					},
   441  				},
   442  			},
   443  		},
   444  	}
   445  }
   446  
   447  func installCRD(apiExtensionClient extclientset.Interface) (*apiextensionsv1.CustomResourceDefinition, error) {
   448  	// CRD borrowed from https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/
   449  	crd := &apiextensionsv1.CustomResourceDefinition{
   450  		ObjectMeta: metav1.ObjectMeta{
   451  			Name: "crontabs.apis.example.com",
   452  		},
   453  		Spec: apiextensionsv1.CustomResourceDefinitionSpec{
   454  			Group: "apis.example.com",
   455  			Scope: apiextensionsv1.NamespaceScoped,
   456  			Names: apiextensionsv1.CustomResourceDefinitionNames{
   457  				Plural:   "crontabs",
   458  				Singular: "crontab",
   459  				Kind:     "CronTab",
   460  				ListKind: "CronTabList",
   461  			},
   462  			Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
   463  				{
   464  					Name:    "v1beta1",
   465  					Served:  true,
   466  					Storage: true,
   467  					Schema: &apiextensionsv1.CustomResourceValidation{
   468  						OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
   469  							XPreserveUnknownFields: pointer.Bool(true),
   470  							Type:                   "object",
   471  							Properties: map[string]apiextensionsv1.JSONSchemaProps{
   472  								"spec": {
   473  									Type: "object",
   474  									Properties: map[string]apiextensionsv1.JSONSchemaProps{
   475  										"cronSpec": {Type: "string"},
   476  										"image":    {Type: "string"},
   477  										"replicas": {Type: "integer"},
   478  									},
   479  								},
   480  							},
   481  						},
   482  					},
   483  				},
   484  			},
   485  		},
   486  	}
   487  
   488  	return apiExtensionClient.ApiextensionsV1().
   489  		CustomResourceDefinitions().Create(context.Background(), crd, metav1.CreateOptions{})
   490  }
   491  
   492  type simpleActivation struct {
   493  	self any
   494  }
   495  
   496  func (a *simpleActivation) ResolveName(name string) (interface{}, bool) {
   497  	switch name {
   498  	case "self":
   499  		return a.self, true
   500  	default:
   501  		return nil, false
   502  	}
   503  }
   504  
   505  func (a *simpleActivation) Parent() interpreter.Activation {
   506  	return nil
   507  }
   508  
   509  func buildTestScheme() *runtime.Scheme {
   510  	// hand-picked schemes that the test API server serves
   511  	scheme := runtime.NewScheme()
   512  	_ = corev1.AddToScheme(scheme)
   513  	_ = appsv1.AddToScheme(scheme)
   514  	_ = admissionregistrationv1.AddToScheme(scheme)
   515  	_ = networkingv1.AddToScheme(scheme)
   516  	_ = nodev1.AddToScheme(scheme)
   517  	_ = storagev1.AddToScheme(scheme)
   518  	_ = apiextensionsscheme.AddToScheme(scheme)
   519  	return scheme
   520  }