diff --git a/staging/src/k8s.io/apiserver/pkg/cel/environment/base.go b/staging/src/k8s.io/apiserver/pkg/cel/environment/base.go index c108bdd644f..5eacb61050c 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/environment/base.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/environment/base.go @@ -130,6 +130,13 @@ var baseOpts = []VersionedOptions{ library.CIDR(), }, }, + // Format Library + { + IntroducedVersion: version.MajorMinor(1, 31), + EnvOptions: []cel.EnvOption{ + library.Format(), + }, + }, } // MustBaseEnvSet returns the common CEL base environments for Kubernetes for Version, or panics diff --git a/staging/src/k8s.io/apiserver/pkg/cel/format.go b/staging/src/k8s.io/apiserver/pkg/cel/format.go new file mode 100644 index 00000000000..1bcfddfe765 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/format.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/google/cel-go/cel" + "github.com/google/cel-go/checker/decls" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +var ( + FormatObject = decls.NewObjectType("kubernetes.NamedFormat") + FormatType = cel.ObjectType("kubernetes.NamedFormat") +) + +// Format provdes a CEL representation of kubernetes format +type Format struct { + Name string + ValidateFunc func(string) []string + + // Size of the regex string or estimated equivalent regex string used + // for cost estimation + MaxRegexSize int +} + +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 { + switch typeVal { + case FormatType: + return d + case types.TypeType: + return FormatType + default: + return types.NewErr("type conversion error from '%s' to '%s'", FormatType, typeVal) + } +} + +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 { + return FormatType +} + +func (d *Format) Value() interface{} { + return d +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/library/cost.go b/staging/src/k8s.io/apiserver/pkg/cel/library/cost.go index 06005b4e806..2be62c0b124 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/library/cost.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/library/cost.go @@ -25,6 +25,7 @@ import ( "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/traits" + "k8s.io/apiserver/pkg/cel" ) // CostEstimator implements CEL's interpretable.ActualCostEstimator and checker.CostEstimator. @@ -152,6 +153,35 @@ func (l *CostEstimator) CallCost(function, overloadId string, args []ref.Val, re cost := uint64(math.Ceil(float64(actualSize(args[0])) * common.StringTraversalCostFactor)) return &cost } + case "validate": + if len(args) >= 2 { + format, isFormat := args[0].Value().(*cel.Format) + if isFormat { + strSize := actualSize(args[1]) + + // Dont have access to underlying regex, estimate a long regexp + regexSize := format.MaxRegexSize + + // Copied from CEL implementation for regex cost + // + // https://swtch.com/~rsc/regexp/regexp1.html applies to RE2 implementation supported by CEL + // Add one to string length for purposes of cost calculation to prevent product of string and regex to be 0 + // in case where string is empty but regex is still expensive. + strCost := uint64(math.Ceil((1.0 + float64(strSize)) * common.StringTraversalCostFactor)) + // We don't know how many expressions are in the regex, just the string length (a huge + // improvement here would be to somehow get a count the number of expressions in the regex or + // how many states are in the regex state machine and use that to measure regex cost). + // For now, we're making a guess that each expression in a regex is typically at least 4 chars + // in length. + regexCost := uint64(math.Ceil(float64(regexSize) * common.RegexStringLengthCostFactor)) + cost := strCost * regexCost + return &cost + } + } + case "format.named": + // Simply dictionary lookup + cost := uint64(1) + return &cost case "sign", "asInteger", "isInteger", "asApproximateFloat", "isGreaterThan", "isLessThan", "compareTo", "add", "sub": cost := uint64(1) return &cost @@ -375,6 +405,13 @@ func (l *CostEstimator) EstimateCallCost(function, overloadId string, target *ch sz := l.sizeEstimate(args[0]) return &checker.CallEstimate{CostEstimate: sz.MultiplyByCostFactor(common.StringTraversalCostFactor)} } + case "validate": + if target != nil { + sz := l.sizeEstimate(args[0]) + return &checker.CallEstimate{CostEstimate: sz.MultiplyByCostFactor(common.StringTraversalCostFactor).MultiplyByCostFactor(cel.MaxNameFormatRegexSize * common.RegexStringLengthCostFactor)} + } + case "format.named": + return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: 1}} case "sign", "asInteger", "isInteger", "asApproximateFloat", "isGreaterThan", "isLessThan", "compareTo", "add", "sub": return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: 1, Max: 1}} } diff --git a/staging/src/k8s.io/apiserver/pkg/cel/library/cost_test.go b/staging/src/k8s.io/apiserver/pkg/cel/library/cost_test.go index 7e1e878eecb..e42cebbf4dc 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/library/cost_test.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/library/cost_test.go @@ -23,6 +23,7 @@ import ( "github.com/google/cel-go/cel" "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/ext" @@ -785,6 +786,42 @@ func TestQuantityCost(t *testing.T) { } } +func TestNameFormatCost(t *testing.T) { + cases := []struct { + name string + expr string + expectEstimatedCost checker.CostEstimate + expectRuntimeCost uint64 + }{ + { + name: "format.named", + expr: `format.named("dns1123subdomain")`, + expectEstimatedCost: checker.CostEstimate{Min: 1, Max: 1}, + expectRuntimeCost: 1, + }, + { + name: "format.dns1123Subdomain.validate", + expr: `format.named("dns1123Subdomain").value().validate("my-name")`, + // Estimated cost doesnt know value at runtime so it is + // using an estimated maximum regex length + expectEstimatedCost: checker.CostEstimate{Min: 34, Max: 34}, + expectRuntimeCost: 17, + }, + { + name: "format.dns1123label.validate", + expr: `format.named("dns1123Label").value().validate("my-name")`, + expectEstimatedCost: checker.CostEstimate{Min: 34, Max: 34}, + expectRuntimeCost: 10, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testCost(t, tc.expr, tc.expectEstimatedCost, tc.expectRuntimeCost) + }) + } +} + func TestSetsCost(t *testing.T) { cases := []struct { name string @@ -1027,6 +1064,8 @@ func testCost(t *testing.T, expr string, expectEsimatedCost checker.CostEstimate ext.Sets(), IP(), CIDR(), + Format(), + cel.OptionalTypes(), // cel-go v0.17.7 introduced CostEstimatorOptions. // Previous the presence has a cost of 0 but cel fixed it to 1. We still set to 0 here to avoid breaking changes. cel.CostEstimatorOptions(checker.PresenceTestHasCost(false)), @@ -1040,7 +1079,11 @@ func testCost(t *testing.T, expr string, expectEsimatedCost checker.CostEstimate } compiled, issues := env.Compile(expr) if len(issues.Errors()) > 0 { - t.Fatalf("%v", issues.Errors()) + var errList []string + for _, issue := range issues.Errors() { + errList = append(errList, issue.ToDisplayString(common.NewTextSource(expr))) + } + t.Fatalf("%v", errList) } estCost, err := env.EstimateCost(compiled, est) if err != nil { diff --git a/staging/src/k8s.io/apiserver/pkg/cel/library/format.go b/staging/src/k8s.io/apiserver/pkg/cel/library/format.go new file mode 100644 index 00000000000..c051f33c006 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/library/format.go @@ -0,0 +1,270 @@ +/* +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 ( + "fmt" + "net/url" + + "github.com/asaskevich/govalidator" + "github.com/google/cel-go/cel" + "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" + "k8s.io/kube-openapi/pkg/validation/strfmt" +) + +// Format provides a CEL library exposing common named Kubernetes string +// validations. Can be used in CRD ValidationRules messageExpression. +// +// Example: +// +// rule: format.dns1123label.validate(object.metadata.name).hasValue() +// messageExpression: format.dns1123label.validate(object.metadata.name).value().join("\n") +// +// format.named(name: string) -> ?Format +// +// Returns the Format with the given name, if it exists. Otherwise, optional.none +// Allowed names are: +// - `dns1123Label` +// - `dns1123Subdomain` +// - `dns1035Label` +// - `qualifiedName` +// - `dns1123LabelPrefix` +// - `dns1123SubdomainPrefix` +// - `dns1035LabelPrefix` +// - `labelValue` +// - `uri` +// - `uuid` +// - `byte` +// - `date` +// - `datetime` +// +// format.() -> Format +// +// Convenience functions for all the named formats are also available +// +// Examples: +// format.dns1123Label().validate("my-label-name") +// format.dns1123Subdomain().validate("apiextensions.k8s.io") +// format.dns1035Label().validate("my-label-name") +// format.qualifiedName().validate("apiextensions.k8s.io/v1beta1") +// format.dns1123LabelPrefix().validate("my-label-prefix-") +// format.dns1123SubdomainPrefix().validate("mysubdomain.prefix.-") +// format.dns1035LabelPrefix().validate("my-label-prefix-") +// format.uri().validate("http://example.com") +// Uses same pattern as isURL, but returns an error +// format.uuid().validate("123e4567-e89b-12d3-a456-426614174000") +// format.byte().validate("aGVsbG8=") +// format.date().validate("2021-01-01") +// format.datetime().validate("2021-01-01T00:00:00Z") +// + +// .validate(str: string) -> ?list +// +// Validates the given string against the given format. Returns optional.none +// if the string is valid, otherwise a list of validation error strings. +func Format() cel.EnvOption { + return cel.Lib(formatLib) +} + +var formatLib = &format{} + +type format struct{} + +func (*format) LibraryName() string { + return "format" +} + +func ZeroArgumentFunctionBinding(binding func() ref.Val) decls.OverloadOpt { + return func(o *decls.OverloadDecl) (*decls.OverloadDecl, error) { + wrapped, err := decls.FunctionBinding(func(values ...ref.Val) ref.Val { return binding() })(o) + if err != nil { + return nil, err + } + if len(wrapped.ArgTypes()) != 0 { + return nil, fmt.Errorf("function binding must have 0 arguments") + } + return o, nil + } +} + +func (*format) CompileOptions() []cel.EnvOption { + options := make([]cel.EnvOption, 0, len(formatLibraryDecls)) + for name, overloads := range formatLibraryDecls { + options = append(options, cel.Function(name, overloads...)) + } + for name, constantValue := range ConstantFormats { + prefixedName := "format." + name + options = append(options, cel.Function(prefixedName, cel.Overload(prefixedName, []*cel.Type{}, apiservercel.FormatType, ZeroArgumentFunctionBinding(func() ref.Val { + return constantValue + })))) + } + return options +} + +func (*format) ProgramOptions() []cel.ProgramOption { + return []cel.ProgramOption{} +} + +var ConstantFormats map[string]*apiservercel.Format = map[string]*apiservercel.Format{ + "dns1123Label": { + Name: "DNS1123Label", + ValidateFunc: func(s string) []string { return apimachineryvalidation.NameIsDNSLabel(s, false) }, + MaxRegexSize: 30, + }, + "dns1123Subdomain": { + Name: "DNS1123Subdomain", + ValidateFunc: func(s string) []string { return apimachineryvalidation.NameIsDNSSubdomain(s, false) }, + MaxRegexSize: 60, + }, + "dns1035Label": { + Name: "DNS1035Label", + ValidateFunc: func(s string) []string { return apimachineryvalidation.NameIsDNS1035Label(s, false) }, + MaxRegexSize: 30, + }, + "qualifiedName": { + Name: "QualifiedName", + ValidateFunc: validation.IsQualifiedName, + MaxRegexSize: 60, // uses subdomain regex + }, + + "dns1123LabelPrefix": { + Name: "DNS1123LabelPrefix", + ValidateFunc: func(s string) []string { return apimachineryvalidation.NameIsDNSLabel(s, true) }, + MaxRegexSize: 30, + }, + "dns1123SubdomainPrefix": { + Name: "DNS1123SubdomainPrefix", + ValidateFunc: func(s string) []string { return apimachineryvalidation.NameIsDNSSubdomain(s, true) }, + MaxRegexSize: 60, + }, + "dns1035LabelPrefix": { + Name: "DNS1035LabelPrefix", + ValidateFunc: func(s string) []string { return apimachineryvalidation.NameIsDNS1035Label(s, true) }, + MaxRegexSize: 30, + }, + "labelValue": { + Name: "LabelValue", + ValidateFunc: validation.IsValidLabelValue, + MaxRegexSize: 40, + }, + + // CRD formats + // Implementations sourced from strfmt, which kube-openapi uses as its + // format library. There are other CRD formats supported, but they are + // covered by other portions of the CEL library (like IP/CIDR), or their + // use is discouraged (like bsonobjectid, email, etc) + "uri": { + Name: "URI", + ValidateFunc: func(s string) []string { + // Directly call ParseRequestURI since we can get a better error message + _, err := url.ParseRequestURI(s) + if err != nil { + return []string{err.Error()} + } + return nil + }, + // Use govalidator url regex to estimate, since ParseRequestURI + // doesnt use regex + MaxRegexSize: len(govalidator.URL), + }, + "uuid": { + Name: "uuid", + ValidateFunc: func(s string) []string { + if !strfmt.Default.Validates("uuid", s) { + return []string{"does not match the UUID format"} + } + return nil + }, + MaxRegexSize: len(strfmt.UUIDPattern), + }, + "byte": { + Name: "byte", + ValidateFunc: func(s string) []string { + if !strfmt.Default.Validates("byte", s) { + return []string{"invalid base64"} + } + return nil + }, + MaxRegexSize: len(govalidator.Base64), + }, + "date": { + Name: "date", + ValidateFunc: func(s string) []string { + if !strfmt.Default.Validates("date", s) { + return []string{"invalid date"} + } + return nil + }, + // Estimated regex size for RFC3339FullDate which is + // a date format. Assume a date-time pattern is longer + // so use that to conservatively estimate this + MaxRegexSize: len(strfmt.DateTimePattern), + }, + "datetime": { + Name: "datetime", + ValidateFunc: func(s string) []string { + if !strfmt.Default.Validates("datetime", s) { + return []string{"invalid datetime"} + } + return nil + }, + MaxRegexSize: len(strfmt.DateTimePattern), + }, +} + +var formatLibraryDecls = map[string][]cel.FunctionOpt{ + "validate": { + cel.MemberOverload("format-validate", []*cel.Type{apiservercel.FormatType, cel.StringType}, cel.OptionalType(cel.ListType(cel.StringType)), cel.BinaryBinding(formatValidate)), + }, + "format.named": { + cel.Overload("format-named", []*cel.Type{cel.StringType}, cel.OptionalType(apiservercel.FormatType), cel.UnaryBinding(func(name ref.Val) ref.Val { + nameString, ok := name.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(name) + } + + f, ok := ConstantFormats[nameString] + if !ok { + return types.OptionalNone + } + return types.OptionalOf(f) + })), + }, +} + +func formatValidate(arg1, arg2 ref.Val) ref.Val { + f, ok := arg1.Value().(*apiservercel.Format) + if !ok { + return types.MaybeNoSuchOverloadErr(arg1) + } + + str, ok := arg2.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(arg2) + } + + res := f.ValidateFunc(str) + if len(res) == 0 { + return types.OptionalNone + } + return types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, res)) +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/library/format_test.go b/staging/src/k8s.io/apiserver/pkg/cel/library/format_test.go new file mode 100644 index 00000000000..d6864f7c666 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/cel/library/format_test.go @@ -0,0 +1,230 @@ +/* +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_test + +import ( + "fmt" + "testing" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "k8s.io/apiserver/pkg/cel/library" +) + +func TestFormat(t *testing.T) { + type testcase struct { + name string + expr string + expectValue ref.Val + expectedCompileErr []string + expectedRuntimeErr string + } + + cases := []testcase{ + { + name: "example_usage_dns1123Label", + expr: `format.dns1123Label().validate("my-label-name")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_dns1123Subdomain", + expr: `format.dns1123Subdomain().validate("apiextensions.k8s.io")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_qualifiedName", + expr: `format.qualifiedName().validate("apiextensions.k8s.io/v1beta1")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_dns1123LabelPrefix", + expr: `format.dns1123LabelPrefix().validate("my-label-prefix-")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_dns1123SubdomainPrefix", + expr: `format.dns1123SubdomainPrefix().validate("mysubdomain.prefix.-")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_dns1035LabelPrefix", + expr: `format.dns1035LabelPrefix().validate("my-label-prefix-")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_uri", + expr: `format.uri().validate("http://example.com")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_uuid", + expr: `format.uuid().validate("123e4567-e89b-12d3-a456-426614174000")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_byte", + expr: `format.byte().validate("aGVsbG8=")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_date", + expr: `format.date().validate("2021-01-01")`, + expectValue: types.OptionalNone, + }, + { + name: "example_usage_datetime", + expr: `format.datetime().validate("2021-01-01T00:00:00Z")`, + expectValue: types.OptionalNone, + }, + { + name: "dns1123Label", + expr: `format.dns1123Label().validate("contains a space")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{"a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character (e.g. 'my-name', or '123-abc', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?')"})), + }, + { + name: "dns1123Subdomain", + expr: `format.dns1123Subdomain().validate("contains a space")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{`a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')`})), + }, + { + name: "dns1035Label", + expr: `format.dns1035Label().validate("contains a space")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{`a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')`})), + }, + { + name: "qualifiedName", + expr: `format.qualifiedName().validate("contains a space")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{`name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')`})), + }, + { + name: "dns1123LabelPrefix", + expr: `format.dns1123LabelPrefix().validate("contains a space-")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{"a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character (e.g. 'my-name', or '123-abc', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?')"})), + }, + { + name: "dns1123SubdomainPrefix", + expr: `format.dns1123SubdomainPrefix().validate("contains a space-")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{`a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')`})), + }, + { + name: "dns1035LabelPrefix", + expr: `format.dns1035LabelPrefix().validate("contains a space-")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{`a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123', regex used for validation is '[a-z]([-a-z0-9]*[a-z0-9])?')`})), + }, + { + name: "dns1123Label_Success", + expr: `format.dns1123Label().validate("my-label-name")`, + expectValue: types.OptionalNone, + }, + { + name: "dns1123Subdomain_Success", + expr: `format.dns1123Subdomain().validate("example.com")`, + expectValue: types.OptionalNone, + }, + { + name: "dns1035Label_Success", + expr: `format.dns1035Label().validate("my-label-name")`, + expectValue: types.OptionalNone, + }, + { + name: "qualifiedName_Success", + expr: `format.qualifiedName().validate("my.name")`, + expectValue: types.OptionalNone, + }, + { + // byte is base64 + name: "byte_success", + expr: `format.byte().validate("aGVsbG8=")`, + expectValue: types.OptionalNone, + }, + { + // byte is base64 + name: "byte_failure", + expr: `format.byte().validate("aGVsbG8")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{"invalid base64"})), + }, + { + name: "date_success", + expr: `format.date().validate("2020-01-01")`, + // date is a valid date + expectValue: types.OptionalNone, + }, + { + name: "date_failure", + expr: `format.date().validate("2020-01-32")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{"invalid date"})), + }, + { + name: "datetime_success", + expr: `format.datetime().validate("2020-01-01T00:00:00Z")`, + // datetime is a valid date + expectValue: types.OptionalNone, + }, + { + name: "datetime_failure", + expr: `format.datetime().validate("2020-01-32T00:00:00Z")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{"invalid datetime"})), + }, + { + name: "unknown_format", + expr: `format.named("unknown").hasValue()`, + expectValue: types.False, + }, + { + name: "labelValue_success", + expr: `format.labelValue().validate("my-cool-label-Value")`, + expectValue: types.OptionalNone, + }, + { + name: "labelValue_failure", + expr: `format.labelValue().validate("my-cool-label-Value!!\n\n!!!")`, + expectValue: types.OptionalOf(types.NewStringList(types.DefaultTypeAdapter, []string{ + "a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')", + })), + }, + } + + // Also test format names and comparisons of all constants + for keyLHS := range library.ConstantFormats { + cases = append(cases, testcase{ + name: "lookup and comparison", + expr: fmt.Sprintf(`format.named("%s").hasValue()`, keyLHS), + expectValue: types.True, + }, testcase{ + name: "comparison with lookup succeeds", + expr: fmt.Sprintf(`format.named("%s").value() == format.%s()`, keyLHS, keyLHS), + expectValue: types.True, + }) + + for keyRHS := range library.ConstantFormats { + if keyLHS == keyRHS { + continue + } + cases = append(cases, testcase{ + name: fmt.Sprintf("compare_%s_%s", keyLHS, keyRHS), + expr: fmt.Sprintf(`format.%s() == format.%s()`, keyLHS, keyRHS), + expectValue: types.False, + }) + } + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testQuantity(t, tc.expr, tc.expectValue, tc.expectedRuntimeErr, tc.expectedCompileErr) + }) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/cel/library/quantity_test.go b/staging/src/k8s.io/apiserver/pkg/cel/library/quantity_test.go index d300b5580ea..1ad28868d15 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/library/quantity_test.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/library/quantity_test.go @@ -21,9 +21,11 @@ import ( "testing" "github.com/google/cel-go/cel" + "github.com/google/cel-go/common" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/ext" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/resource" @@ -34,11 +36,13 @@ import ( func testQuantity(t *testing.T, expr string, expectResult ref.Val, expectRuntimeErrPattern string, expectCompileErrs []string) { env, err := cel.NewEnv( + cel.OptionalTypes(), ext.Strings(), library.URLs(), library.Regex(), library.Lists(), library.Quantity(), + library.Format(), ) if err != nil { t.Fatalf("%v", err) @@ -79,7 +83,12 @@ func testQuantity(t *testing.T, expr string, expectResult ref.Val, expectRuntime require.Empty(t, missingCompileErrs, "expected compilation errors") return } else if len(issues.Errors()) > 0 { - t.Fatalf("%v", issues.Errors()) + errorStrings := []string{} + source := common.NewTextSource(expr) + for _, issue := range issues.Errors() { + errorStrings = append(errorStrings, issue.ToDisplayString(source)) + } + t.Fatalf("%v", errorStrings) } // Typecheck expression @@ -105,7 +114,7 @@ func testQuantity(t *testing.T, expr string, expectResult ref.Val, expectRuntime t.Fatalf("%v", err) } else if expectResult != nil { converted := res.Equal(expectResult).Value().(bool) - require.True(t, converted, "expectation not equal to output") + require.True(t, converted, "expectation not equal to output: %v", cmp.Diff(expectResult.Value(), res.Value())) } else { t.Fatal("expected result must not be nil") } diff --git a/staging/src/k8s.io/apiserver/pkg/cel/limits.go b/staging/src/k8s.io/apiserver/pkg/cel/limits.go index 65c6ad5f3a3..66ab4e44c91 100644 --- a/staging/src/k8s.io/apiserver/pkg/cel/limits.go +++ b/staging/src/k8s.io/apiserver/pkg/cel/limits.go @@ -47,4 +47,6 @@ const ( MinBoolSize = 4 // MinNumberSize is the length of literal 0 MinNumberSize = 1 + + MaxNameFormatRegexSize = 128 )