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 }