diff --git a/go.mod b/go.mod index df7fe42a0..ac2a5cefb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ go 1.22.0 require ( 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-systemd/v22 v22.5.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/component-base v0.0.0-20240906193739-83f63c39727c 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/utils v0.0.0-20240711033017-18e509b52bc8 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/antlr4-go/antlr/v4 v4.13.0 // 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/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect diff --git a/go.sum b/go.sum index 4c1bd8d6b..36c4de38f 100644 --- a/go.sum +++ b/go.sum @@ -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/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 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-20240827234606-4aa59ca2ab14/go.mod h1:ySeb/iDDdPkL9xksNTkh5F8l7/YykHrMnozHdH+gD0k= +k8s.io/kms v0.0.0-20240911031427-5781cdc762b1 h1:ei7efe802YLti9U57pZi3j7RvD5HRffTNDDWoEBAi90= +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/go.mod h1:coRQXBK9NxO98XUv3ZD6AK3xzHCxV6+b7lrquKwaKzA= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= diff --git a/pkg/cel/environment/base_test.go b/pkg/cel/environment/base_test.go index a21891115..2b68f6b26 100644 --- a/pkg/cel/environment/base_test.go +++ b/pkg/cel/environment/base_test.go @@ -18,11 +18,14 @@ package environment import ( "sort" + "strings" "testing" "github.com/google/cel-go/cel" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/version" + "k8s.io/apiserver/pkg/cel/library" ) // 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 { env, err := cel.NewCustomEnv() if err != nil { diff --git a/pkg/cel/format.go b/pkg/cel/format.go index 1bcfddfe7..31216806f 100644 --- a/pkg/cel/format.go +++ b/pkg/cel/format.go @@ -41,11 +41,11 @@ type Format struct { 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) } -func (d *Format) ConvertToType(typeVal ref.Type) ref.Val { +func (d Format) ConvertToType(typeVal ref.Type) ref.Val { switch typeVal { case FormatType: return d @@ -56,18 +56,18 @@ func (d *Format) ConvertToType(typeVal ref.Type) ref.Val { } } -func (d *Format) Equal(other ref.Val) ref.Val { - otherDur, ok := other.(*Format) +func (d Format) Equal(other ref.Val) ref.Val { + otherDur, ok := other.(Format) if !ok { return types.MaybeNoSuchOverloadErr(other) } return types.Bool(d.Name == otherDur.Name) } -func (d *Format) Type() ref.Type { +func (d Format) Type() ref.Type { return FormatType } -func (d *Format) Value() interface{} { +func (d Format) Value() interface{} { return d } diff --git a/pkg/cel/library/authz.go b/pkg/cel/library/authz.go index 1fd489fc9..77332cff8 100644 --- a/pkg/cel/library/authz.go +++ b/pkg/cel/library/authz.go @@ -232,7 +232,20 @@ var authzLib = &authz{} type authz struct{} 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{ @@ -324,7 +337,15 @@ var authzSelectorsLib = &authzSelectors{} type authzSelectors struct{} 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{ diff --git a/pkg/cel/library/cidr.go b/pkg/cel/library/cidr.go index c4259daed..2992e99e6 100644 --- a/pkg/cel/library/cidr.go +++ b/pkg/cel/library/cidr.go @@ -109,7 +109,15 @@ var cidrsLib = &cidrs{} type cidrs struct{} 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{ diff --git a/pkg/cel/library/cost.go b/pkg/cel/library/cost.go index 63863088c..14b74dc6b 100644 --- a/pkg/cel/library/cost.go +++ b/pkg/cel/library/cost.go @@ -18,17 +18,14 @@ package library import ( "fmt" - "math" - "reflect" - "github.com/google/cel-go/checker" "github.com/google/cel-go/common" "github.com/google/cel-go/common/ast" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/traits" + "math" - "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apiserver/pkg/cel" ) @@ -50,22 +47,6 @@ var knownUnhandledFunctions = map[string]bool{ "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. type CostEstimator struct { // 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": if len(args) >= 2 { - format, isFormat := args[0].Value().(*cel.Format) + format, isFormat := args[0].Value().(cel.Format) if isFormat { strSize := actualSize(args[1]) @@ -258,18 +239,17 @@ func (l *CostEstimator) CallCost(function, overloadId string, args []ref.Val, re unitCost := uint64(1) lhs := args[0] switch lhs.(type) { - case cel.Quantity: - return &unitCost - case cel.IP: - return &unitCost - case cel.CIDR: - return &unitCost - case *cel.Format: // Formats have a small max size. - return &unitCost - case cel.URL: // TODO: Computing the actual cost is expensive, and changing this would be a breaking change + case *cel.Quantity, cel.Quantity, + *cel.IP, cel.IP, + *cel.CIDR, cel.CIDR, + *cel.Format, cel.Format, // Formats have a small max size. Format takes pointer receiver. + *cel.URL, cel.URL, // TODO: Computing the actual cost is expensive, and changing this would be a breaking change + *cel.Semver, cel.Semver, + *authorizerVal, authorizerVal, *pathCheckVal, pathCheckVal, *groupCheckVal, groupCheckVal, + *resourceCheckVal, resourceCheckVal, *decisionVal, decisionVal: return &unitCost 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)) } } @@ -528,7 +508,8 @@ func (l *CostEstimator) EstimateCallCost(function, overloadId string, target *ch } if t.Kind() == types.StructKind { 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}} case cel.FormatType: 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)} } } - if panicOnUnknown && knownKubernetesCompilerTypes.Has(t) { + if panicOnUnknown && isRegisteredType(t.TypeName()) { panic(fmt.Errorf("EstimateCallCost: unhandled equality for Kubernetes type %v", t)) } } diff --git a/pkg/cel/library/cost_test.go b/pkg/cel/library/cost_test.go index de4eaf009..4da1934f6 100644 --- a/pkg/cel/library/cost_test.go +++ b/pkg/cel/library/cost_test.go @@ -19,6 +19,7 @@ package library import ( "context" "fmt" + "github.com/google/cel-go/common/types/ref" "testing" "github.com/google/cel-go/cel" @@ -30,6 +31,7 @@ import ( exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1" "k8s.io/apiserver/pkg/authorization/authorizer" + apiservercel "k8s.io/apiserver/pkg/cel" ) const ( @@ -1231,10 +1233,10 @@ func TestSize(t *testing.T) { est := &CostEstimator{SizeEstimator: &testCostEstimator{}} for _, tc := range cases { 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)) 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) 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 + 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 } -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 } -func (t testSizeNode) Expr() ast.Expr { - return nil // not needed -} - -func (t testSizeNode) ComputedSize() *checker.SizeEstimate { +func (t testNode) ComputedSize() *checker.SizeEstimate { return &t.size } diff --git a/pkg/cel/library/format.go b/pkg/cel/library/format.go index c051f33c0..82ecffb41 100644 --- a/pkg/cel/library/format.go +++ b/pkg/cel/library/format.go @@ -25,6 +25,7 @@ import ( "github.com/google/cel-go/common/decls" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" "k8s.io/apimachinery/pkg/util/validation" apiservercel "k8s.io/apiserver/pkg/cel" @@ -90,7 +91,15 @@ var formatLib = &format{} type format struct{} 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 { @@ -124,7 +133,7 @@ func (*format) ProgramOptions() []cel.ProgramOption { return []cel.ProgramOption{} } -var ConstantFormats map[string]*apiservercel.Format = map[string]*apiservercel.Format{ +var ConstantFormats = map[string]apiservercel.Format{ "dns1123Label": { Name: "DNS1123Label", 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 { - f, ok := arg1.Value().(*apiservercel.Format) + f, ok := arg1.Value().(apiservercel.Format) if !ok { return types.MaybeNoSuchOverloadErr(arg1) } diff --git a/pkg/cel/library/ip.go b/pkg/cel/library/ip.go index cdfeb1daf..8edc4463a 100644 --- a/pkg/cel/library/ip.go +++ b/pkg/cel/library/ip.go @@ -132,7 +132,15 @@ var ipLib = &ip{} type ip struct{} 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{ diff --git a/pkg/cel/library/libraries.go b/pkg/cel/library/libraries.go new file mode 100644 index 000000000..e3689e3e0 --- /dev/null +++ b/pkg/cel/library/libraries.go @@ -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 +} diff --git a/pkg/cel/library/library_compatibility_test.go b/pkg/cel/library/library_compatibility_test.go index 81691d556..50b5d2288 100644 --- a/pkg/cel/library/library_compatibility_test.go +++ b/pkg/cel/library/library_compatibility_test.go @@ -17,19 +17,22 @@ limitations under the License. package library import ( - "testing" - "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" ) 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]() - for _, lib := range libs { - for name := range lib { + for _, lib := range KnownLibraries() { + 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{}{} } } @@ -50,7 +53,7 @@ func TestLibraryCompatibility(t *testing.T) { // Kubernetes <1.30>: "ip", "family", "isUnspecified", "isLoopback", "isLinkLocalMulticast", "isLinkLocalUnicast", "isGlobalUnicast", "ip.isCanonical", "isIP", "cidr", "containsIP", "containsCIDR", "masked", "prefixLength", "isCIDR", "string", // Kubernetes <1.31>: - "fieldSelector", "labelSelector", "validate", "format.named", + "fieldSelector", "labelSelector", "validate", "format.named", "isSemver", "major", "minor", "patch", "semver", // 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) } } + +// 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") diff --git a/pkg/cel/library/lists.go b/pkg/cel/library/lists.go index 327ec93d6..1f61b1181 100644 --- a/pkg/cel/library/lists.go +++ b/pkg/cel/library/lists.go @@ -96,7 +96,15 @@ var listsLib = &lists{} type lists struct{} 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") diff --git a/pkg/cel/library/quantity.go b/pkg/cel/library/quantity.go index b4ac91c8a..236b366b4 100644 --- a/pkg/cel/library/quantity.go +++ b/pkg/cel/library/quantity.go @@ -143,7 +143,15 @@ var quantityLib = &quantity{} type quantity struct{} 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{ diff --git a/pkg/cel/library/regex.go b/pkg/cel/library/regex.go index 147a40f9b..2cf8b0037 100644 --- a/pkg/cel/library/regex.go +++ b/pkg/cel/library/regex.go @@ -52,7 +52,15 @@ var regexLib = ®ex{} type regex struct{} 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{ diff --git a/pkg/cel/library/semver_test.go b/pkg/cel/library/semver_test.go new file mode 100644 index 000000000..3b1471dda --- /dev/null +++ b/pkg/cel/library/semver_test.go @@ -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) + }) + } +} diff --git a/pkg/cel/library/semverlib.go b/pkg/cel/library/semverlib.go new file mode 100644 index 000000000..d8c79ae02 --- /dev/null +++ b/pkg/cel/library/semverlib.go @@ -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() +// +// 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( ) +// +// 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. +// +// .major() +// +// 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 +// +// +// .isLessThan() +// .isGreaterThan() +// .compareTo() +// +// 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)) +} diff --git a/pkg/cel/library/test.go b/pkg/cel/library/test.go index dcbc058a1..282d93962 100644 --- a/pkg/cel/library/test.go +++ b/pkg/cel/library/test.go @@ -38,7 +38,7 @@ type testLib struct { } func (*testLib) LibraryName() string { - return "k8s.test" + return "kubernetes.test" } type TestOption func(*testLib) *testLib diff --git a/pkg/cel/library/urls.go b/pkg/cel/library/urls.go index 8f4ba85af..4b7ffb95a 100644 --- a/pkg/cel/library/urls.go +++ b/pkg/cel/library/urls.go @@ -113,7 +113,15 @@ var urlsLib = &urls{} type urls struct{} 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{ diff --git a/pkg/cel/semver.go b/pkg/cel/semver.go new file mode 100644 index 000000000..c53b9c306 --- /dev/null +++ b/pkg/cel/semver.go @@ -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 +}