Merge pull request #126368 from jpbetz/organize-cel-libraries

Improve structure of CEL libraries to ensure cost tests kept accurate with introduction of new types

Kubernetes-commit: e3a81ab0005973f25d498308702f826795c6d8c2
This commit is contained in:
Kubernetes Publisher 2024-09-11 20:41:19 +01:00
commit 438d2ea372
20 changed files with 834 additions and 73 deletions

4
go.mod
View File

@ -6,6 +6,7 @@ go 1.22.0
require ( require (
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a
github.com/blang/semver/v4 v4.0.0
github.com/coreos/go-oidc v2.2.1+incompatible github.com/coreos/go-oidc v2.2.1+incompatible
github.com/coreos/go-systemd/v22 v22.5.0 github.com/coreos/go-systemd/v22 v22.5.0
github.com/emicklei/go-restful/v3 v3.11.0 github.com/emicklei/go-restful/v3 v3.11.0
@ -51,7 +52,7 @@ require (
k8s.io/client-go v0.0.0-20240910233403-8d0bbdfdcc60 k8s.io/client-go v0.0.0-20240910233403-8d0bbdfdcc60
k8s.io/component-base v0.0.0-20240906193739-83f63c39727c k8s.io/component-base v0.0.0-20240906193739-83f63c39727c
k8s.io/klog/v2 v2.130.1 k8s.io/klog/v2 v2.130.1
k8s.io/kms v0.0.0-20240827234606-4aa59ca2ab14 k8s.io/kms v0.0.0-20240911031427-5781cdc762b1
k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2 k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 k8s.io/utils v0.0.0-20240711033017-18e509b52bc8
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3
@ -64,7 +65,6 @@ require (
github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect

4
go.sum
View File

@ -381,8 +381,8 @@ k8s.io/component-base v0.0.0-20240906193739-83f63c39727c h1:Gdhz79FA3l8bKFBM1eYC
k8s.io/component-base v0.0.0-20240906193739-83f63c39727c/go.mod h1:roKR20Tbxxh1HGxr9Fc1EzHkKJMH9Wfkau/U/EgjXys= k8s.io/component-base v0.0.0-20240906193739-83f63c39727c/go.mod h1:roKR20Tbxxh1HGxr9Fc1EzHkKJMH9Wfkau/U/EgjXys=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kms v0.0.0-20240827234606-4aa59ca2ab14 h1:Ds3NwJtIZQZRU1kSOl83F/KPh+RBH41NqPhnhNL34ck= k8s.io/kms v0.0.0-20240911031427-5781cdc762b1 h1:ei7efe802YLti9U57pZi3j7RvD5HRffTNDDWoEBAi90=
k8s.io/kms v0.0.0-20240827234606-4aa59ca2ab14/go.mod h1:ySeb/iDDdPkL9xksNTkh5F8l7/YykHrMnozHdH+gD0k= k8s.io/kms v0.0.0-20240911031427-5781cdc762b1/go.mod h1:ySeb/iDDdPkL9xksNTkh5F8l7/YykHrMnozHdH+gD0k=
k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2 h1:GKE9U8BH16uynoxQii0auTjmmmuZ3O0LFMN6S0lPPhI= k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2 h1:GKE9U8BH16uynoxQii0auTjmmmuZ3O0LFMN6S0lPPhI=
k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2/go.mod h1:coRQXBK9NxO98XUv3ZD6AK3xzHCxV6+b7lrquKwaKzA= k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2/go.mod h1:coRQXBK9NxO98XUv3ZD6AK3xzHCxV6+b7lrquKwaKzA=
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=

View File

@ -18,11 +18,14 @@ package environment
import ( import (
"sort" "sort"
"strings"
"testing" "testing"
"github.com/google/cel-go/cel" "github.com/google/cel-go/cel"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/version" "k8s.io/apimachinery/pkg/util/version"
"k8s.io/apiserver/pkg/cel/library"
) )
// BenchmarkLoadBaseEnv is expected to be very fast, because a // BenchmarkLoadBaseEnv is expected to be very fast, because a
@ -112,6 +115,29 @@ func TestLibraryCoverage(t *testing.T) {
} }
} }
// TestKnownLibraries ensures that all libraries used in the base environment are also registered with
// KnownLibraries. Other tests rely on KnownLibraries to provide an up-to-date list of CEL libraries.
func TestKnownLibraries(t *testing.T) {
known := sets.New[string]()
used := sets.New[string]()
for _, lib := range library.KnownLibraries() {
known.Insert(lib.LibraryName())
}
for _, libName := range MustBaseEnvSet(version.MajorMinor(1, 0), true).storedExpressions.Libraries() {
if strings.HasPrefix(libName, "cel.lib") { // ignore core libs
continue
}
used.Insert(libName)
}
unexpected := used.Difference(known)
if len(unexpected) != 0 {
t.Errorf("Expected all libraries in the base environment to be included in k8s.io/apiserver/pkg/cel/library's KnownLibraries, but found missing libraries: %v", unexpected)
}
}
func librariesInVersions(t *testing.T, vops ...VersionedOptions) []string { func librariesInVersions(t *testing.T, vops ...VersionedOptions) []string {
env, err := cel.NewCustomEnv() env, err := cel.NewCustomEnv()
if err != nil { if err != nil {

View File

@ -41,11 +41,11 @@ type Format struct {
MaxRegexSize int MaxRegexSize int
} }
func (d *Format) ConvertToNative(typeDesc reflect.Type) (interface{}, error) { func (d Format) ConvertToNative(typeDesc reflect.Type) (interface{}, error) {
return nil, fmt.Errorf("type conversion error from 'Format' to '%v'", typeDesc) return nil, fmt.Errorf("type conversion error from 'Format' to '%v'", typeDesc)
} }
func (d *Format) ConvertToType(typeVal ref.Type) ref.Val { func (d Format) ConvertToType(typeVal ref.Type) ref.Val {
switch typeVal { switch typeVal {
case FormatType: case FormatType:
return d return d
@ -56,18 +56,18 @@ func (d *Format) ConvertToType(typeVal ref.Type) ref.Val {
} }
} }
func (d *Format) Equal(other ref.Val) ref.Val { func (d Format) Equal(other ref.Val) ref.Val {
otherDur, ok := other.(*Format) otherDur, ok := other.(Format)
if !ok { if !ok {
return types.MaybeNoSuchOverloadErr(other) return types.MaybeNoSuchOverloadErr(other)
} }
return types.Bool(d.Name == otherDur.Name) return types.Bool(d.Name == otherDur.Name)
} }
func (d *Format) Type() ref.Type { func (d Format) Type() ref.Type {
return FormatType return FormatType
} }
func (d *Format) Value() interface{} { func (d Format) Value() interface{} {
return d return d
} }

View File

@ -232,7 +232,20 @@ var authzLib = &authz{}
type authz struct{} type authz struct{}
func (*authz) LibraryName() string { func (*authz) LibraryName() string {
return "k8s.authz" return "kubernetes.authz"
}
func (*authz) Types() []*cel.Type {
return []*cel.Type{
AuthorizerType,
PathCheckType,
GroupCheckType,
ResourceCheckType,
DecisionType}
}
func (*authz) declarations() map[string][]cel.FunctionOpt {
return authzLibraryDecls
} }
var authzLibraryDecls = map[string][]cel.FunctionOpt{ var authzLibraryDecls = map[string][]cel.FunctionOpt{
@ -324,7 +337,15 @@ var authzSelectorsLib = &authzSelectors{}
type authzSelectors struct{} type authzSelectors struct{}
func (*authzSelectors) LibraryName() string { func (*authzSelectors) LibraryName() string {
return "k8s.authzSelectors" return "kubernetes.authzSelectors"
}
func (*authzSelectors) Types() []*cel.Type {
return []*cel.Type{ResourceCheckType}
}
func (*authzSelectors) declarations() map[string][]cel.FunctionOpt {
return authzSelectorsLibraryDecls
} }
var authzSelectorsLibraryDecls = map[string][]cel.FunctionOpt{ var authzSelectorsLibraryDecls = map[string][]cel.FunctionOpt{

View File

@ -109,7 +109,15 @@ var cidrsLib = &cidrs{}
type cidrs struct{} type cidrs struct{}
func (*cidrs) LibraryName() string { func (*cidrs) LibraryName() string {
return "net.cidr" return "kubernetes.net.cidr"
}
func (*cidrs) declarations() map[string][]cel.FunctionOpt {
return cidrLibraryDecls
}
func (*cidrs) Types() []*cel.Type {
return []*cel.Type{apiservercel.CIDRType, apiservercel.IPType}
} }
var cidrLibraryDecls = map[string][]cel.FunctionOpt{ var cidrLibraryDecls = map[string][]cel.FunctionOpt{

View File

@ -18,17 +18,14 @@ package library
import ( import (
"fmt" "fmt"
"math"
"reflect"
"github.com/google/cel-go/checker" "github.com/google/cel-go/checker"
"github.com/google/cel-go/common" "github.com/google/cel-go/common"
"github.com/google/cel-go/common/ast" "github.com/google/cel-go/common/ast"
"github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits" "github.com/google/cel-go/common/types/traits"
"math"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/cel" "k8s.io/apiserver/pkg/cel"
) )
@ -50,22 +47,6 @@ var knownUnhandledFunctions = map[string]bool{
"strings.quote": true, "strings.quote": true,
} }
// TODO: Replace this with a utility that extracts types from libraries.
var knownKubernetesRuntimeTypes = sets.New[reflect.Type](
reflect.ValueOf(cel.URL{}).Type(),
reflect.ValueOf(cel.IP{}).Type(),
reflect.ValueOf(cel.CIDR{}).Type(),
reflect.ValueOf(&cel.Format{}).Type(),
reflect.ValueOf(cel.Quantity{}).Type(),
)
var knownKubernetesCompilerTypes = sets.New[ref.Type](
cel.CIDRType,
cel.IPType,
cel.FormatType,
cel.QuantityType,
cel.URLType,
)
// CostEstimator implements CEL's interpretable.ActualCostEstimator and checker.CostEstimator. // CostEstimator implements CEL's interpretable.ActualCostEstimator and checker.CostEstimator.
type CostEstimator struct { type CostEstimator struct {
// SizeEstimator provides a CostEstimator.EstimateSize that this CostEstimator will delegate size estimation // SizeEstimator provides a CostEstimator.EstimateSize that this CostEstimator will delegate size estimation
@ -219,7 +200,7 @@ func (l *CostEstimator) CallCost(function, overloadId string, args []ref.Val, re
} }
case "validate": case "validate":
if len(args) >= 2 { if len(args) >= 2 {
format, isFormat := args[0].Value().(*cel.Format) format, isFormat := args[0].Value().(cel.Format)
if isFormat { if isFormat {
strSize := actualSize(args[1]) strSize := actualSize(args[1])
@ -258,18 +239,17 @@ func (l *CostEstimator) CallCost(function, overloadId string, args []ref.Val, re
unitCost := uint64(1) unitCost := uint64(1)
lhs := args[0] lhs := args[0]
switch lhs.(type) { switch lhs.(type) {
case cel.Quantity: case *cel.Quantity, cel.Quantity,
return &unitCost *cel.IP, cel.IP,
case cel.IP: *cel.CIDR, cel.CIDR,
return &unitCost *cel.Format, cel.Format, // Formats have a small max size. Format takes pointer receiver.
case cel.CIDR: *cel.URL, cel.URL, // TODO: Computing the actual cost is expensive, and changing this would be a breaking change
return &unitCost *cel.Semver, cel.Semver,
case *cel.Format: // Formats have a small max size. *authorizerVal, authorizerVal, *pathCheckVal, pathCheckVal, *groupCheckVal, groupCheckVal,
return &unitCost *resourceCheckVal, resourceCheckVal, *decisionVal, decisionVal:
case cel.URL: // TODO: Computing the actual cost is expensive, and changing this would be a breaking change
return &unitCost return &unitCost
default: default:
if panicOnUnknown && knownKubernetesRuntimeTypes.Has(reflect.ValueOf(lhs).Type()) { if panicOnUnknown && lhs.Type() != nil && isRegisteredType(lhs.Type().TypeName()) {
panic(fmt.Errorf("CallCost: unhandled equality for Kubernetes type %T", lhs)) panic(fmt.Errorf("CallCost: unhandled equality for Kubernetes type %T", lhs))
} }
} }
@ -528,7 +508,8 @@ func (l *CostEstimator) EstimateCallCost(function, overloadId string, target *ch
} }
if t.Kind() == types.StructKind { if t.Kind() == types.StructKind {
switch t { switch t {
case cel.QuantityType: // O(1) cost equality checks case cel.QuantityType, AuthorizerType, PathCheckType, // O(1) cost equality checks
GroupCheckType, ResourceCheckType, DecisionType, cel.SemverType:
return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: 1}} return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: 1}}
case cel.FormatType: case cel.FormatType:
return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: cel.MaxFormatSize}.MultiplyByCostFactor(common.StringTraversalCostFactor)} return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: cel.MaxFormatSize}.MultiplyByCostFactor(common.StringTraversalCostFactor)}
@ -542,7 +523,7 @@ func (l *CostEstimator) EstimateCallCost(function, overloadId string, target *ch
return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: size.Max}.MultiplyByCostFactor(common.StringTraversalCostFactor)} return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: size.Max}.MultiplyByCostFactor(common.StringTraversalCostFactor)}
} }
} }
if panicOnUnknown && knownKubernetesCompilerTypes.Has(t) { if panicOnUnknown && isRegisteredType(t.TypeName()) {
panic(fmt.Errorf("EstimateCallCost: unhandled equality for Kubernetes type %v", t)) panic(fmt.Errorf("EstimateCallCost: unhandled equality for Kubernetes type %v", t))
} }
} }

View File

@ -19,6 +19,7 @@ package library
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/google/cel-go/common/types/ref"
"testing" "testing"
"github.com/google/cel-go/cel" "github.com/google/cel-go/cel"
@ -30,6 +31,7 @@ import (
exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1" exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
"k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/authorization/authorizer"
apiservercel "k8s.io/apiserver/pkg/cel"
) )
const ( const (
@ -1231,10 +1233,10 @@ func TestSize(t *testing.T) {
est := &CostEstimator{SizeEstimator: &testCostEstimator{}} est := &CostEstimator{SizeEstimator: &testCostEstimator{}}
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
var targetNode checker.AstNode = testSizeNode{size: tc.targetSize} var targetNode checker.AstNode = testNode{size: tc.targetSize}
argNodes := make([]checker.AstNode, len(tc.argSizes)) argNodes := make([]checker.AstNode, len(tc.argSizes))
for i, arg := range tc.argSizes { for i, arg := range tc.argSizes {
argNodes[i] = testSizeNode{size: arg} argNodes[i] = testNode{size: arg}
} }
result := est.EstimateCallCost(tc.function, tc.overload, &targetNode, argNodes) result := est.EstimateCallCost(tc.function, tc.overload, &targetNode, argNodes)
if result.ResultSize == nil { if result.ResultSize == nil {
@ -1247,25 +1249,63 @@ func TestSize(t *testing.T) {
} }
} }
type testSizeNode struct { // TestTypeEquality ensures that cost is tested for all custom types used by Kubernetes libraries.
func TestTypeEquality(t *testing.T) {
examples := map[string]ref.Val{
// Add example ref.Val's for custom types in Kubernetes here:
"kubernetes.authorization.Authorizer": authorizerVal{},
"kubernetes.authorization.PathCheck": pathCheckVal{},
"kubernetes.authorization.GroupCheck": groupCheckVal{},
"kubernetes.authorization.ResourceCheck": resourceCheckVal{},
"kubernetes.authorization.Decision": decisionVal{},
"kubernetes.URL": apiservercel.URL{},
"kubernetes.Quantity": apiservercel.Quantity{},
"net.IP": apiservercel.IP{},
"net.CIDR": apiservercel.CIDR{},
"kubernetes.NamedFormat": apiservercel.Format{},
"kubernetes.Semver": apiservercel.Semver{},
}
originalPanicOnUnknown := panicOnUnknown
panicOnUnknown = true
t.Cleanup(func() { panicOnUnknown = originalPanicOnUnknown })
est := &CostEstimator{SizeEstimator: &testCostEstimator{}}
for _, lib := range KnownLibraries() {
for _, kt := range lib.Types() {
t.Run(kt.TypeName(), func(t *testing.T) {
typeNode := testNode{size: checker.SizeEstimate{Min: 10, Max: 100}, typ: kt}
est.EstimateCallCost("_==_", "", nil, []checker.AstNode{typeNode, typeNode})
ex, ok := examples[kt.TypeName()]
if !ok {
t.Errorf("missing example for type: %s", kt.TypeName())
}
est.CallCost("_==_", "", []ref.Val{ex, ex}, nil)
})
}
}
}
type testNode struct {
size checker.SizeEstimate size checker.SizeEstimate
typ *types.Type
} }
var _ checker.AstNode = (*testSizeNode)(nil) var _ checker.AstNode = (*testNode)(nil)
func (t testSizeNode) Path() []string { func (t testNode) Path() []string {
return nil // not needed return nil // not needed
} }
func (t testSizeNode) Type() *types.Type { func (t testNode) Type() *types.Type {
return t.typ // not needed
}
func (t testNode) Expr() ast.Expr {
return nil // not needed return nil // not needed
} }
func (t testSizeNode) Expr() ast.Expr { func (t testNode) ComputedSize() *checker.SizeEstimate {
return nil // not needed
}
func (t testSizeNode) ComputedSize() *checker.SizeEstimate {
return &t.size return &t.size
} }

View File

@ -25,6 +25,7 @@ import (
"github.com/google/cel-go/common/decls" "github.com/google/cel-go/common/decls"
"github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/ref"
apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation"
"k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation"
apiservercel "k8s.io/apiserver/pkg/cel" apiservercel "k8s.io/apiserver/pkg/cel"
@ -90,7 +91,15 @@ var formatLib = &format{}
type format struct{} type format struct{}
func (*format) LibraryName() string { func (*format) LibraryName() string {
return "format" return "kubernetes.format"
}
func (*format) Types() []*cel.Type {
return []*cel.Type{apiservercel.FormatType}
}
func (*format) declarations() map[string][]cel.FunctionOpt {
return formatLibraryDecls
} }
func ZeroArgumentFunctionBinding(binding func() ref.Val) decls.OverloadOpt { func ZeroArgumentFunctionBinding(binding func() ref.Val) decls.OverloadOpt {
@ -124,7 +133,7 @@ func (*format) ProgramOptions() []cel.ProgramOption {
return []cel.ProgramOption{} return []cel.ProgramOption{}
} }
var ConstantFormats map[string]*apiservercel.Format = map[string]*apiservercel.Format{ var ConstantFormats = map[string]apiservercel.Format{
"dns1123Label": { "dns1123Label": {
Name: "DNS1123Label", Name: "DNS1123Label",
ValidateFunc: func(s string) []string { return apimachineryvalidation.NameIsDNSLabel(s, false) }, ValidateFunc: func(s string) []string { return apimachineryvalidation.NameIsDNSLabel(s, false) },
@ -252,7 +261,7 @@ var formatLibraryDecls = map[string][]cel.FunctionOpt{
} }
func formatValidate(arg1, arg2 ref.Val) ref.Val { func formatValidate(arg1, arg2 ref.Val) ref.Val {
f, ok := arg1.Value().(*apiservercel.Format) f, ok := arg1.Value().(apiservercel.Format)
if !ok { if !ok {
return types.MaybeNoSuchOverloadErr(arg1) return types.MaybeNoSuchOverloadErr(arg1)
} }

View File

@ -132,7 +132,15 @@ var ipLib = &ip{}
type ip struct{} type ip struct{}
func (*ip) LibraryName() string { func (*ip) LibraryName() string {
return "net.ip" return "kubernetes.net.ip"
}
func (*ip) declarations() map[string][]cel.FunctionOpt {
return ipLibraryDecls
}
func (*ip) Types() []*cel.Type {
return []*cel.Type{apiservercel.IPType}
} }
var ipLibraryDecls = map[string][]cel.FunctionOpt{ var ipLibraryDecls = map[string][]cel.FunctionOpt{

View File

@ -0,0 +1,60 @@
/*
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 library
import (
"github.com/google/cel-go/cel"
)
// Library represents a CEL library used by kubernetes.
type Library interface {
// SingletonLibrary provides the library name and ensures the library can be safely registered into environments.
cel.SingletonLibrary
// Types provides all custom types introduced by the library.
Types() []*cel.Type
// declarations returns all function declarations provided by the library.
declarations() map[string][]cel.FunctionOpt
}
// KnownLibraries returns all libraries used in Kubernetes.
func KnownLibraries() []Library {
return []Library{
authzLib,
authzSelectorsLib,
listsLib,
regexLib,
urlsLib,
quantityLib,
ipLib,
cidrsLib,
formatLib,
semverLib,
}
}
func isRegisteredType(typeName string) bool {
for _, lib := range KnownLibraries() {
for _, rt := range lib.Types() {
if rt.TypeName() == typeName {
return true
}
}
}
return false
}

View File

@ -17,19 +17,22 @@ limitations under the License.
package library package library
import ( import (
"testing"
"github.com/google/cel-go/cel" "github.com/google/cel-go/cel"
"github.com/google/cel-go/common/decls"
"github.com/google/cel-go/common/types"
"strings"
"testing"
"k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/sets"
) )
func TestLibraryCompatibility(t *testing.T) { func TestLibraryCompatibility(t *testing.T) {
var libs []map[string][]cel.FunctionOpt
libs = append(libs, authzLibraryDecls, listsLibraryDecls, regexLibraryDecls, urlLibraryDecls, quantityLibraryDecls, ipLibraryDecls, cidrLibraryDecls, formatLibraryDecls, authzSelectorsLibraryDecls)
functionNames := sets.New[string]() functionNames := sets.New[string]()
for _, lib := range libs { for _, lib := range KnownLibraries() {
for name := range lib { if !strings.HasPrefix(lib.LibraryName(), "kubernetes.") {
t.Errorf("Expected all kubernetes CEL libraries to have a name package with a 'kubernetes.' prefix but got %v", lib.LibraryName())
}
for name := range lib.declarations() {
functionNames[name] = struct{}{} functionNames[name] = struct{}{}
} }
} }
@ -50,7 +53,7 @@ func TestLibraryCompatibility(t *testing.T) {
// Kubernetes <1.30>: // Kubernetes <1.30>:
"ip", "family", "isUnspecified", "isLoopback", "isLinkLocalMulticast", "isLinkLocalUnicast", "isGlobalUnicast", "ip.isCanonical", "isIP", "cidr", "containsIP", "containsCIDR", "masked", "prefixLength", "isCIDR", "string", "ip", "family", "isUnspecified", "isLoopback", "isLinkLocalMulticast", "isLinkLocalUnicast", "isGlobalUnicast", "ip.isCanonical", "isIP", "cidr", "containsIP", "containsCIDR", "masked", "prefixLength", "isCIDR", "string",
// Kubernetes <1.31>: // Kubernetes <1.31>:
"fieldSelector", "labelSelector", "validate", "format.named", "fieldSelector", "labelSelector", "validate", "format.named", "isSemver", "major", "minor", "patch", "semver",
// Kubernetes <1.??>: // Kubernetes <1.??>:
) )
@ -66,3 +69,46 @@ func TestLibraryCompatibility(t *testing.T) {
t.Errorf("Expected all functions in the libraries to be assigned to a kubernetes release, but found the missing function names: %v", missing) t.Errorf("Expected all functions in the libraries to be assigned to a kubernetes release, but found the missing function names: %v", missing)
} }
} }
// TestTypeRegistration ensures that all custom types defined and used by Kubernetes CEL libraries
// are returned by library.Types(). Other tests depend on Types() to provide an up-to-date list of
// types declared in a library.
func TestTypeRegistration(t *testing.T) {
for _, lib := range KnownLibraries() {
registeredTypes := sets.New[*cel.Type]()
usedTypes := sets.New[*cel.Type]()
// scan all registered function declarations for the library
for _, fn := range lib.declarations() {
fn, err := decls.NewFunction("placeholder-not-used", fn...)
if err != nil {
t.Fatal(err)
}
for _, o := range fn.OverloadDecls() {
// ArgTypes include both the receiver type (if present) and
// all function argument types.
for _, at := range o.ArgTypes() {
switch at.Kind() {
// User defined types are either Opaque or Struct.
case types.OpaqueKind, types.StructKind:
usedTypes.Insert(at)
default:
// skip
}
}
}
}
for _, lb := range lib.Types() {
registeredTypes.Insert(lb)
if !strings.HasPrefix(lb.TypeName(), "kubernetes.") && !legacyTypeNames.Has(lb.TypeName()) {
t.Errorf("Expected all types in kubernetes CEL libraries to have a type name packaged with a 'kubernetes.' prefix but got %v", lb.TypeName())
}
}
unregistered := usedTypes.Difference(registeredTypes)
if len(unregistered) != 0 {
t.Errorf("Expected types to be registered with the %s library Type() functions, but they were not: %v", lib.LibraryName(), unregistered)
}
}
}
// TODO: Consider renaming these to "kubernetes.net.IP" and "kubernetes.net.CIDR" if we decide not to promote them to cel-go
var legacyTypeNames = sets.New[string]("net.IP", "net.CIDR")

View File

@ -96,7 +96,15 @@ var listsLib = &lists{}
type lists struct{} type lists struct{}
func (*lists) LibraryName() string { func (*lists) LibraryName() string {
return "k8s.lists" return "kubernetes.lists"
}
func (*lists) Types() []*cel.Type {
return []*cel.Type{}
}
func (*lists) declarations() map[string][]cel.FunctionOpt {
return listsLibraryDecls
} }
var paramA = cel.TypeParamType("A") var paramA = cel.TypeParamType("A")

View File

@ -143,7 +143,15 @@ var quantityLib = &quantity{}
type quantity struct{} type quantity struct{}
func (*quantity) LibraryName() string { func (*quantity) LibraryName() string {
return "k8s.quantity" return "kubernetes.quantity"
}
func (*quantity) Types() []*cel.Type {
return []*cel.Type{apiservercel.QuantityType}
}
func (*quantity) declarations() map[string][]cel.FunctionOpt {
return quantityLibraryDecls
} }
var quantityLibraryDecls = map[string][]cel.FunctionOpt{ var quantityLibraryDecls = map[string][]cel.FunctionOpt{

View File

@ -52,7 +52,15 @@ var regexLib = &regex{}
type regex struct{} type regex struct{}
func (*regex) LibraryName() string { func (*regex) LibraryName() string {
return "k8s.regex" return "kubernetes.regex"
}
func (*regex) Types() []*cel.Type {
return []*cel.Type{}
}
func (*regex) declarations() map[string][]cel.FunctionOpt {
return regexLibraryDecls
} }
var regexLibraryDecls = map[string][]cel.FunctionOpt{ var regexLibraryDecls = map[string][]cel.FunctionOpt{

View File

@ -0,0 +1,210 @@
/*
Copyright 2023 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 library_test
import (
"regexp"
"testing"
"github.com/blang/semver/v4"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/sets"
apiservercel "k8s.io/apiserver/pkg/cel"
library "k8s.io/apiserver/pkg/cel/library"
)
func testSemver(t *testing.T, expr string, expectResult ref.Val, expectRuntimeErrPattern string, expectCompileErrs []string) {
env, err := cel.NewEnv(
library.SemverLib(),
)
if err != nil {
t.Fatalf("%v", err)
}
compiled, issues := env.Compile(expr)
if len(expectCompileErrs) > 0 {
missingCompileErrs := []string{}
matchedCompileErrs := sets.New[int]()
for _, expectedCompileErr := range expectCompileErrs {
compiledPattern, err := regexp.Compile(expectedCompileErr)
if err != nil {
t.Fatalf("failed to compile expected err regex: %v", err)
}
didMatch := false
for i, compileError := range issues.Errors() {
if compiledPattern.Match([]byte(compileError.Message)) {
didMatch = true
matchedCompileErrs.Insert(i)
}
}
if !didMatch {
missingCompileErrs = append(missingCompileErrs, expectedCompileErr)
} else if len(matchedCompileErrs) != len(issues.Errors()) {
unmatchedErrs := []cel.Error{}
for i, issue := range issues.Errors() {
if !matchedCompileErrs.Has(i) {
unmatchedErrs = append(unmatchedErrs, *issue)
}
}
require.Empty(t, unmatchedErrs, "unexpected compilation errors")
}
}
require.Empty(t, missingCompileErrs, "expected compilation errors")
return
} else if len(issues.Errors()) > 0 {
for _, err := range issues.Errors() {
t.Errorf("unexpected compile error: %v", err)
}
t.FailNow()
}
prog, err := env.Program(compiled)
if err != nil {
t.Fatalf("%v", err)
}
res, _, err := prog.Eval(map[string]interface{}{})
if len(expectRuntimeErrPattern) > 0 {
if err == nil {
t.Fatalf("no runtime error thrown. Expected: %v", expectRuntimeErrPattern)
} else if matched, regexErr := regexp.MatchString(expectRuntimeErrPattern, err.Error()); regexErr != nil {
t.Fatalf("failed to compile expected err regex: %v", regexErr)
} else if !matched {
t.Fatalf("unexpected err: %v", err)
}
} else if err != nil {
t.Fatalf("%v", err)
} else if expectResult != nil {
converted := res.Equal(expectResult).Value().(bool)
require.True(t, converted, "expectation not equal to output")
} else {
t.Fatal("expected result must not be nil")
}
}
func TestSemver(t *testing.T) {
trueVal := types.Bool(true)
falseVal := types.Bool(false)
cases := []struct {
name string
expr string
expectValue ref.Val
expectedCompileErr []string
expectedRuntimeErr string
}{
{
name: "parse",
expr: `semver("1.2.3")`,
expectValue: apiservercel.Semver{Version: semver.MustParse("1.2.3")},
},
{
name: "parseInvalidVersion",
expr: `semver("v1.0")`,
expectedRuntimeErr: "No Major.Minor.Patch elements found",
},
{
name: "isSemver",
expr: `isSemver("1.2.3-beta.1+build.1")`,
expectValue: trueVal,
},
{
name: "isSemver_false",
expr: `isSemver("v1.0")`,
expectValue: falseVal,
},
{
name: "isSemver_noOverload",
expr: `isSemver([1, 2, 3])`,
expectedCompileErr: []string{"found no matching overload for 'isSemver' applied to.*"},
},
{
name: "equality_reflexivity",
expr: `semver("1.2.3") == semver("1.2.3")`,
expectValue: trueVal,
},
{
name: "inequality",
expr: `semver("1.2.3") == semver("1.0.0")`,
expectValue: falseVal,
},
{
name: "semver_less",
expr: `semver("1.0.0").isLessThan(semver("1.2.3"))`,
expectValue: trueVal,
},
{
name: "semver_less_false",
expr: `semver("1.0.0").isLessThan(semver("1.0.0"))`,
expectValue: falseVal,
},
{
name: "semver_greater",
expr: `semver("1.2.3").isGreaterThan(semver("1.0.0"))`,
expectValue: trueVal,
},
{
name: "semver_greater_false",
expr: `semver("1.0.0").isGreaterThan(semver("1.0.0"))`,
expectValue: falseVal,
},
{
name: "compare_equal",
expr: `semver("1.2.3").compareTo(semver("1.2.3"))`,
expectValue: types.Int(0),
},
{
name: "compare_less",
expr: `semver("1.0.0").compareTo(semver("1.2.3"))`,
expectValue: types.Int(-1),
},
{
name: "compare_greater",
expr: `semver("1.2.3").compareTo(semver("1.0.0"))`,
expectValue: types.Int(1),
},
{
name: "major",
expr: `semver("1.2.3").major()`,
expectValue: types.Int(1),
},
{
name: "minor",
expr: `semver("1.2.3").minor()`,
expectValue: types.Int(2),
},
{
name: "patch",
expr: `semver("1.2.3").patch()`,
expectValue: types.Int(3),
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
testSemver(t, c.expr, c.expectValue, c.expectedRuntimeErr, c.expectedCompileErr)
})
}
}

View File

@ -0,0 +1,247 @@
/*
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 library
import (
"github.com/blang/semver/v4"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
apiservercel "k8s.io/apiserver/pkg/cel"
)
// Semver provides a CEL function library extension for [semver.Version].
//
// semver
//
// Converts a string to a semantic version or results in an error if the string is not a valid semantic version. Refer
// to semver.org documentation for information on accepted patterns.
//
// semver(<string>) <Semver>
//
// Examples:
//
// semver('1.0.0') // returns a Semver
// semver('0.1.0-alpha.1') // returns a Semver
// semver('200K') // error
// semver('Three') // error
// semver('Mi') // error
//
// isSemver
//
// Returns true if a string is a valid Semver. isSemver returns true if and
// only if semver does not result in error.
//
// isSemver( <string>) <bool>
//
// Examples:
//
// isSemver('1.0.0') // returns true
// isSemver('v1.0') // returns true (tolerant parsing)
// isSemver('hello') // returns false
//
// Conversion to Scalars:
//
// - major/minor/patch: return the major version number as int64.
//
// <Semver>.major() <int>
//
// Examples:
//
// semver("1.2.3").major() // returns 1
//
// Comparisons
//
// - isGreaterThan: Returns true if and only if the receiver is greater than the operand
//
// - isLessThan: Returns true if and only if the receiver is less than the operand
//
// - compareTo: Compares receiver to operand and returns 0 if they are equal, 1 if the receiver is greater, or -1 if the receiver is less than the operand
//
//
// <Semver>.isLessThan(<semver>) <bool>
// <Semver>.isGreaterThan(<semver>) <bool>
// <Semver>.compareTo(<semver>) <int>
//
// Examples:
//
// semver("1.2.3").compareTo(semver("1.2.3")) // returns 0
// semver("1.2.3").compareTo(semver("2.0.0")) // returns -1
// semver("1.2.3").compareTo(semver("0.1.2")) // returns 1
func SemverLib() cel.EnvOption {
return cel.Lib(semverLib)
}
var semverLib = &semverLibType{}
type semverLibType struct{}
func (*semverLibType) LibraryName() string {
return "kubernetes.Semver"
}
func (*semverLibType) Types() []*cel.Type {
return []*cel.Type{apiservercel.SemverType}
}
func (*semverLibType) declarations() map[string][]cel.FunctionOpt {
return map[string][]cel.FunctionOpt{
"semver": {
cel.Overload("string_to_semver", []*cel.Type{cel.StringType}, apiservercel.SemverType, cel.UnaryBinding((stringToSemver))),
},
"isSemver": {
cel.Overload("is_semver_string", []*cel.Type{cel.StringType}, cel.BoolType, cel.UnaryBinding(isSemver)),
},
"isGreaterThan": {
cel.MemberOverload("semver_is_greater_than", []*cel.Type{apiservercel.SemverType, apiservercel.SemverType}, cel.BoolType, cel.BinaryBinding(semverIsGreaterThan)),
},
"isLessThan": {
cel.MemberOverload("semver_is_less_than", []*cel.Type{apiservercel.SemverType, apiservercel.SemverType}, cel.BoolType, cel.BinaryBinding(semverIsLessThan)),
},
"compareTo": {
cel.MemberOverload("semver_compare_to", []*cel.Type{apiservercel.SemverType, apiservercel.SemverType}, cel.IntType, cel.BinaryBinding(semverCompareTo)),
},
"major": {
cel.MemberOverload("semver_major", []*cel.Type{apiservercel.SemverType}, cel.IntType, cel.UnaryBinding(semverMajor)),
},
"minor": {
cel.MemberOverload("semver_minor", []*cel.Type{apiservercel.SemverType}, cel.IntType, cel.UnaryBinding(semverMinor)),
},
"patch": {
cel.MemberOverload("semver_patch", []*cel.Type{apiservercel.SemverType}, cel.IntType, cel.UnaryBinding(semverPatch)),
},
}
}
func (s *semverLibType) CompileOptions() []cel.EnvOption {
// Defined in this function to avoid an initialization order problem.
semverLibraryDecls := s.declarations()
options := make([]cel.EnvOption, 0, len(semverLibraryDecls))
for name, overloads := range semverLibraryDecls {
options = append(options, cel.Function(name, overloads...))
}
return options
}
func (*semverLibType) ProgramOptions() []cel.ProgramOption {
return []cel.ProgramOption{}
}
func isSemver(arg ref.Val) ref.Val {
str, ok := arg.Value().(string)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
// Using semver/v4 here is okay because this function isn't
// used to validate the Kubernetes API. In the CEL base library
// we would have to use the regular expression from
// pkg/apis/resource/structured/namedresources/validation/validation.go.
_, err := semver.Parse(str)
if err != nil {
return types.Bool(false)
}
return types.Bool(true)
}
func stringToSemver(arg ref.Val) ref.Val {
str, ok := arg.Value().(string)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
// Using semver/v4 here is okay because this function isn't
// used to validate the Kubernetes API. In the CEL base library
// we would have to use the regular expression from
// pkg/apis/resource/structured/namedresources/validation/validation.go
// first before parsing.
v, err := semver.Parse(str)
if err != nil {
return types.WrapErr(err)
}
return apiservercel.Semver{Version: v}
}
func semverMajor(arg ref.Val) ref.Val {
v, ok := arg.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
return types.Int(v.Major)
}
func semverMinor(arg ref.Val) ref.Val {
v, ok := arg.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
return types.Int(v.Minor)
}
func semverPatch(arg ref.Val) ref.Val {
v, ok := arg.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
return types.Int(v.Patch)
}
func semverIsGreaterThan(arg ref.Val, other ref.Val) ref.Val {
v, ok := arg.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
v2, ok := other.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
return types.Bool(v.Compare(v2) == 1)
}
func semverIsLessThan(arg ref.Val, other ref.Val) ref.Val {
v, ok := arg.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
v2, ok := other.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
return types.Bool(v.Compare(v2) == -1)
}
func semverCompareTo(arg ref.Val, other ref.Val) ref.Val {
v, ok := arg.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
v2, ok := other.Value().(semver.Version)
if !ok {
return types.MaybeNoSuchOverloadErr(arg)
}
return types.Int(v.Compare(v2))
}

View File

@ -38,7 +38,7 @@ type testLib struct {
} }
func (*testLib) LibraryName() string { func (*testLib) LibraryName() string {
return "k8s.test" return "kubernetes.test"
} }
type TestOption func(*testLib) *testLib type TestOption func(*testLib) *testLib

View File

@ -113,7 +113,15 @@ var urlsLib = &urls{}
type urls struct{} type urls struct{}
func (*urls) LibraryName() string { func (*urls) LibraryName() string {
return "k8s.urls" return "kubernetes.urls"
}
func (*urls) Types() []*cel.Type {
return []*cel.Type{apiservercel.URLType}
}
func (*urls) declarations() map[string][]cel.FunctionOpt {
return urlLibraryDecls
} }
var urlLibraryDecls = map[string][]cel.FunctionOpt{ var urlLibraryDecls = map[string][]cel.FunctionOpt{

73
pkg/cel/semver.go Normal file
View File

@ -0,0 +1,73 @@
/*
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 cel
import (
"fmt"
"reflect"
"github.com/blang/semver/v4"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)
var (
SemverType = cel.ObjectType("kubernetes.Semver")
)
// Semver provdes a CEL representation of a [semver.Version].
type Semver struct {
semver.Version
}
func (v Semver) ConvertToNative(typeDesc reflect.Type) (interface{}, error) {
if reflect.TypeOf(v.Version).AssignableTo(typeDesc) {
return v.Version, nil
}
if reflect.TypeOf("").AssignableTo(typeDesc) {
return v.Version.String(), nil
}
return nil, fmt.Errorf("type conversion error from 'Semver' to '%v'", typeDesc)
}
func (v Semver) ConvertToType(typeVal ref.Type) ref.Val {
switch typeVal {
case SemverType:
return v
case types.TypeType:
return SemverType
default:
return types.NewErr("type conversion error from '%s' to '%s'", SemverType, typeVal)
}
}
func (v Semver) Equal(other ref.Val) ref.Val {
otherDur, ok := other.(Semver)
if !ok {
return types.MaybeNoSuchOverloadErr(other)
}
return types.Bool(v.Version.EQ(otherDur.Version))
}
func (v Semver) Type() ref.Type {
return SemverType
}
func (v Semver) Value() interface{} {
return v.Version
}