Merge pull request #108419 from DangerOnTheRanger/cel-maxlength-integration

CEL MaxLength integration
This commit is contained in:
Kubernetes Prow Robot 2022-03-16 13:50:11 -07:00 committed by GitHub
commit 1d7599b56c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1386 additions and 33 deletions

View File

@ -22,6 +22,7 @@ import (
"time"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker"
"github.com/google/cel-go/checker/decls"
expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
"google.golang.org/protobuf/proto"
@ -54,10 +55,11 @@ const (
type CompilationResult struct {
Program cel.Program
Error *Error
// If true, the compiled expression contains a reference to the identifier "oldSelf", and its corresponding rule
// is implicitly a transition rule.
TransitionRule bool
// Represents the worst-case cost of the compiled expression in terms of CEL's cost units, as used by cel.EstimateCost.
MaxCost uint64
}
// Compile compiles all the XValidations rules (without recursing into the schema) and returns a slice containing a
@ -111,17 +113,17 @@ func Compile(s *schema.Structural, isResourceRoot bool, perCallLimit uint64) ([]
if err != nil {
return nil, err
}
estimator := celEstimator{root: root}
// compResults is the return value which saves a list of compilation results in the same order as x-kubernetes-validations rules.
compResults := make([]CompilationResult, len(celRules))
for i, rule := range celRules {
compResults[i] = compileRule(rule, env, perCallLimit)
compResults[i] = compileRule(rule, env, perCallLimit, &estimator)
}
return compResults, nil
}
func compileRule(rule apiextensions.ValidationRule, env *cel.Env, perCallLimit uint64) (compilationResult CompilationResult) {
func compileRule(rule apiextensions.ValidationRule, env *cel.Env, perCallLimit uint64, estimator *celEstimator) (compilationResult CompilationResult) {
if len(strings.TrimSpace(rule.Rule)) == 0 {
// include a compilation result, but leave both program and error nil per documented return semantics of this
// function
@ -156,7 +158,12 @@ func compileRule(rule apiextensions.ValidationRule, env *cel.Env, perCallLimit u
compilationResult.Error = &Error{ErrorTypeInvalid, "program instantiation failed: " + err.Error()}
return
}
costEst, err := env.EstimateCost(ast, estimator)
if err != nil {
compilationResult.Error = &Error{ErrorTypeInternal, "cost estimation failed: " + err.Error()}
return
}
compilationResult.MaxCost = costEst.Max
compilationResult.Program = prog
return
}
@ -168,3 +175,45 @@ func compileRule(rule apiextensions.ValidationRule, env *cel.Env, perCallLimit u
func generateUniqueSelfTypeName() string {
return fmt.Sprintf("selfType%d", time.Now().Nanosecond())
}
type celEstimator struct {
root *celmodel.DeclType
}
func (c *celEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate {
if len(element.Path()) == 0 {
// Path() can return an empty list, early exit if it does since we can't
// provide size estimates when that happens
return nil
}
currentNode := c.root
// cut off "self" from path, since we always start there
for _, name := range element.Path()[1:] {
switch name {
case "@items", "@values":
if currentNode.ElemType == nil {
return nil
}
currentNode = currentNode.ElemType
case "@keys":
if currentNode.KeyType == nil {
return nil
}
currentNode = currentNode.KeyType
default:
field, ok := currentNode.Fields[name]
if !ok {
return nil
}
if field.Type == nil {
return nil
}
currentNode = field.Type
}
}
return &checker.SizeEstimate{Min: 0, Max: uint64(currentNode.MaxElements)}
}
func (c *celEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate {
return nil
}

View File

@ -18,6 +18,7 @@ package cel
import (
"fmt"
"math"
"strings"
"testing"
@ -25,6 +26,10 @@ import (
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
)
const (
costLimit = 100000000
)
type validationMatcher interface {
matches(cr CompilationResult) bool
String() string
@ -658,3 +663,864 @@ func TestCelCompilation(t *testing.T) {
})
}
}
// take a single rule type in (string/number/map/etc.) and return appropriate values for
// Type, Format, and XIntOrString
func parseRuleType(ruleType string) (string, string, bool) {
if ruleType == "duration" || ruleType == "date" || ruleType == "date-time" {
return "string", ruleType, false
}
if ruleType == "int-or-string" {
return "", "", true
}
return ruleType, "", false
}
func genArrayWithRule(arrayType, rule string) func(maxItems *int64) *schema.Structural {
passedType, passedFormat, xIntString := parseRuleType(arrayType)
return func(maxItems *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: passedType,
},
ValueValidation: &schema.ValueValidation{
Format: passedFormat,
},
Extensions: schema.Extensions{
XIntOrString: xIntString,
},
},
ValueValidation: &schema.ValueValidation{
MaxItems: maxItems,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genArrayOfArraysWithRule(arrayType, rule string) func(maxItems *int64) *schema.Structural {
return func(maxItems *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: arrayType,
},
},
},
ValueValidation: &schema.ValueValidation{
MaxItems: maxItems,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genObjectArrayWithRule(rule string) func(maxItems *int64) *schema.Structural {
return func(maxItems *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: "object",
},
Properties: map[string]schema.Structural{
"required": {
Generic: schema.Generic{
Type: "string",
},
},
"optional": {
Generic: schema.Generic{
Type: "string",
},
},
},
ValueValidation: &schema.ValueValidation{
Required: []string{"required"},
},
},
ValueValidation: &schema.ValueValidation{
MaxItems: maxItems,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func getMapArrayWithRule(mapType, rule string) func(maxItems *int64) *schema.Structural {
return func(maxItems *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: &schema.Structural{
Generic: schema.Generic{
Type: mapType,
},
}},
},
},
ValueValidation: &schema.ValueValidation{
MaxItems: maxItems,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genMapWithRule(mapType, rule string) func(maxProperties *int64) *schema.Structural {
passedType, passedFormat, xIntString := parseRuleType(mapType)
return func(maxProperties *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: &schema.Structural{
Generic: schema.Generic{
Type: passedType,
},
ValueValidation: &schema.ValueValidation{
Format: passedFormat,
},
Extensions: schema.Extensions{
XIntOrString: xIntString,
},
}},
},
ValueValidation: &schema.ValueValidation{
MaxProperties: maxProperties,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genStringWithRule(rule string) func(maxLength *int64) *schema.Structural {
return func(maxLength *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
MaxLength: maxLength,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genBytesWithRule(rule string) func(maxLength *int64) *schema.Structural {
return func(maxLength *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
MaxLength: maxLength,
Format: "byte",
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genNestedSpecWithRule(rule string) func(maxLength *int64) *schema.Structural {
return func(maxLength *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
MaxLength: maxLength,
},
}},
},
ValueValidation: &schema.ValueValidation{
MaxProperties: maxLength,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genAllMaxNestedSpecWithRootRule(rule string) func(maxLength *int64) *schema.Structural {
return func(maxLength *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: &schema.Structural{
Generic: schema.Generic{
Type: "object",
},
ValueValidation: &schema.ValueValidation{
Required: []string{"required"},
MaxProperties: maxLength,
},
Properties: map[string]schema.Structural{
"required": {
Generic: schema.Generic{
Type: "string",
},
},
"optional": {
Generic: schema.Generic{
Type: "string",
},
},
},
}},
},
ValueValidation: &schema.ValueValidation{
MaxProperties: maxLength,
},
},
ValueValidation: &schema.ValueValidation{
MaxItems: maxLength,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genOneMaxNestedSpecWithRootRule(rule string) func(maxLength *int64) *schema.Structural {
return func(maxLength *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: &schema.Structural{
Generic: schema.Generic{
Type: "object",
},
ValueValidation: &schema.ValueValidation{
Required: []string{"required"},
},
Properties: map[string]schema.Structural{
"required": {
Generic: schema.Generic{
Type: "string",
},
},
"optional": {
Generic: schema.Generic{
Type: "string",
},
},
},
}},
},
ValueValidation: &schema.ValueValidation{
MaxProperties: maxLength,
},
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func genObjectForMap() *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "object",
},
Properties: map[string]schema.Structural{
"required": {
Generic: schema.Generic{
Type: "string",
},
},
"optional": {
Generic: schema.Generic{
Type: "string",
},
},
},
ValueValidation: &schema.ValueValidation{
Required: []string{"required"},
},
}
}
func genArrayForMap() *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: "number",
},
},
}
}
func genMapForMap() *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: &schema.Structural{
Generic: schema.Generic{
Type: "number",
},
}},
},
}
}
func genMapWithCustomItemRule(item *schema.Structural, rule string) func(maxProperties *int64) *schema.Structural {
return func(maxProperties *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: item},
},
ValueValidation: &schema.ValueValidation{
MaxProperties: maxProperties,
},
Extensions: schema.Extensions{
XValidations: apiextensions.ValidationRules{
{
Rule: rule,
},
},
},
}
}
}
func schemaChecker(schema *schema.Structural, expectedCost uint64, calcLimit uint64, t *testing.T) func(t *testing.T) {
return func(t *testing.T) {
// TODO(DangerOnTheRanger): if perCallLimit in compilation.go changes, this needs to change as well
compilationResults, err := Compile(schema, false, uint64(math.MaxInt64))
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if len(compilationResults) != 1 {
t.Errorf("Expected one rule, got: %d", len(compilationResults))
}
result := compilationResults[0]
if result.Error != nil {
t.Errorf("Expected no compile-time error, got: %v", result.Error)
}
if calcLimit == 0 {
if result.MaxCost != expectedCost {
t.Errorf("Wrong cost (expected %d, got %d)", expectedCost, result.MaxCost)
}
} else {
if result.MaxCost < calcLimit {
t.Errorf("Cost did not exceed limit as expected (expected more than %d, got %d)", calcLimit, result.MaxCost)
}
}
}
}
func TestCostEstimation(t *testing.T) {
cases := []struct {
name string
schemaGenerator func(maxLength *int64) *schema.Structural
expectedCalcCost uint64
setMaxElements int64
expectedSetCost uint64
expectCalcCostExceedsLimit uint64
}{
{
name: "number array with all",
schemaGenerator: genArrayWithRule("number", "self.all(x, true)"),
expectedCalcCost: 4718591,
setMaxElements: 10,
expectedSetCost: 32,
},
{
name: "string array with all",
schemaGenerator: genArrayWithRule("string", "self.all(x, true)"),
expectedCalcCost: 3145727,
setMaxElements: 20,
expectedSetCost: 62,
},
{
name: "boolean array with all",
schemaGenerator: genArrayWithRule("boolean", "self.all(x, true)"),
expectedCalcCost: 1887437,
setMaxElements: 5,
expectedSetCost: 17,
},
// all array-of-array tests should have the same expected cost along the same expression,
// since arrays-of-arrays are serialized the same in minimized form regardless of item type
// of the subarray ([[], [], ...])
{
name: "array of number arrays with all",
schemaGenerator: genArrayOfArraysWithRule("number", "self.all(x, true)"),
expectedCalcCost: 3145727,
setMaxElements: 100,
expectedSetCost: 302,
},
{
name: "array of objects with all",
schemaGenerator: genObjectArrayWithRule("self.all(x, true)"),
expectedCalcCost: 555128,
setMaxElements: 50,
expectedSetCost: 152,
},
{
name: "map of numbers with all",
schemaGenerator: genMapWithRule("number", "self.all(x, true)"),
expectedCalcCost: 1348169,
setMaxElements: 10,
expectedSetCost: 32,
},
{
name: "map of numbers with has",
schemaGenerator: genMapWithRule("number", "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 100,
expectedSetCost: 0,
},
{
name: "map of strings with all",
schemaGenerator: genMapWithRule("string", "self.all(x, true)"),
expectedCalcCost: 1179647,
setMaxElements: 3,
expectedSetCost: 11,
},
{
name: "map of strings with has",
schemaGenerator: genMapWithRule("string", "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 550,
expectedSetCost: 0,
},
{
name: "map of booleans with all",
schemaGenerator: genMapWithRule("boolean", "self.all(x, true)"),
expectedCalcCost: 943718,
setMaxElements: 100,
expectedSetCost: 302,
},
{
name: "map of booleans with has",
schemaGenerator: genMapWithRule("boolean", "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 1024,
expectedSetCost: 0,
},
{
name: "string with contains",
schemaGenerator: genStringWithRule("self.contains('test')"),
expectedCalcCost: 314574,
setMaxElements: 10,
expectedSetCost: 5,
},
{
name: "string with startsWith",
schemaGenerator: genStringWithRule("self.startsWith('test')"),
expectedCalcCost: 2,
setMaxElements: 15,
expectedSetCost: 2,
},
{
name: "string with endsWith",
schemaGenerator: genStringWithRule("self.endsWith('test')"),
expectedCalcCost: 2,
setMaxElements: 30,
expectedSetCost: 2,
},
{
name: "concat string",
schemaGenerator: genStringWithRule(`size(self + "hello") > size("hello")`),
expectedCalcCost: 314578,
setMaxElements: 4,
expectedSetCost: 7,
},
{
name: "index of array with numbers",
schemaGenerator: genArrayWithRule("number", "self[1] == 0.0"),
expectedCalcCost: 2,
setMaxElements: 5000,
expectedSetCost: 2,
},
{
name: "index of array with strings",
schemaGenerator: genArrayWithRule("string", "self[1] == self[1]"),
expectedCalcCost: 314577,
setMaxElements: 8,
expectedSetCost: 314577,
},
{
name: "O(n^2) loop with numbers",
schemaGenerator: genArrayWithRule("number", "self.all(x, self.all(y, true))"),
expectedCalcCost: 9895601504256,
expectCalcCostExceedsLimit: costLimit,
setMaxElements: 10,
expectedSetCost: 352,
},
{
name: "O(n^3) loop with numbers",
schemaGenerator: genArrayWithRule("number", "self.all(x, self.all(y, self.all(z, true)))"),
expectedCalcCost: 13499986500008999998,
expectCalcCostExceedsLimit: costLimit,
setMaxElements: 10,
expectedSetCost: 3552,
},
{
name: "regex matches simple",
schemaGenerator: genStringWithRule(`self.matches("x")`),
expectedCalcCost: 314574,
setMaxElements: 50,
expectedSetCost: 22,
},
{
name: "regex matches empty string",
schemaGenerator: genStringWithRule(`"".matches("(((((((((())))))))))[0-9]")`),
expectedCalcCost: 7,
setMaxElements: 10,
expectedSetCost: 7,
},
{
name: "regex matches empty regex",
schemaGenerator: genStringWithRule(`self.matches("")`),
expectedCalcCost: 1,
setMaxElements: 100,
expectedSetCost: 1,
},
{
name: "map of strings with value length",
schemaGenerator: genNestedSpecWithRule("self.all(x, x.contains(self[x]))"),
expectedCalcCost: 2752507,
setMaxElements: 10,
expectedSetCost: 72,
},
{
name: "set array maxLength to zero",
schemaGenerator: genArrayWithRule("number", "self[3] == 0.0"),
expectedCalcCost: 2,
setMaxElements: 0,
expectedSetCost: 2,
},
{
name: "set map maxLength to zero",
schemaGenerator: genMapWithRule("number", `self["x"] == 0.0`),
expectedCalcCost: 2,
setMaxElements: 0,
expectedSetCost: 2,
},
{
name: "set string maxLength to zero",
schemaGenerator: genStringWithRule(`self == "x"`),
expectedCalcCost: 2,
setMaxElements: 0,
expectedSetCost: 1,
},
{
name: "set bytes maxLength to zero",
schemaGenerator: genBytesWithRule(`self == b"x"`),
expectedCalcCost: 2,
setMaxElements: 0,
expectedSetCost: 1,
},
{
name: "set maxLength greater than estimated maxLength",
schemaGenerator: genArrayWithRule("number", "self.all(x, x == 0.0)"),
expectedCalcCost: 6291454,
setMaxElements: 3 * 1024 * 2048,
expectedSetCost: 25165826,
},
{
name: "nested types with root rule with all supporting maxLength",
schemaGenerator: genAllMaxNestedSpecWithRootRule(`self.all(x, x["y"].required == "z")`),
expectedCalcCost: 7340027,
setMaxElements: 10,
expectedSetCost: 72,
},
{
name: "nested types with root rule with one supporting maxLength",
schemaGenerator: genOneMaxNestedSpecWithRootRule(`self.all(x, x["y"].required == "z")`),
expectedCalcCost: 7340027,
setMaxElements: 10,
expectedSetCost: 7340027,
},
{
name: "int-or-string array with all",
schemaGenerator: genArrayWithRule("int-or-string", "self.all(x, true)"),
expectedCalcCost: 4718591,
setMaxElements: 10,
expectedSetCost: 32,
},
{
name: "index of array with int-or-strings",
schemaGenerator: genArrayWithRule("int-or-string", "self[0] == 5"),
expectedCalcCost: 3,
setMaxElements: 10,
expectedSetCost: 3,
},
{
name: "index of array with booleans",
schemaGenerator: genArrayWithRule("boolean", "self[0] == false"),
expectedCalcCost: 2,
setMaxElements: 25,
expectedSetCost: 2,
},
{
name: "index of array of objects",
schemaGenerator: genObjectArrayWithRule("self[0] == null"),
expectedCalcCost: 2,
setMaxElements: 422,
expectedSetCost: 2,
},
{
name: "index of array of array of numnbers",
schemaGenerator: genArrayOfArraysWithRule("number", "self[0][0] == -1.0"),
expectedCalcCost: 3,
setMaxElements: 51,
expectedSetCost: 3,
},
{
name: "array of number maps with all",
schemaGenerator: getMapArrayWithRule("number", `self.all(x, x.y == 25.2)`),
expectedCalcCost: 6291452,
setMaxElements: 12,
expectedSetCost: 74,
},
{
name: "index of array of number maps",
schemaGenerator: getMapArrayWithRule("number", `self[0].x > 2.0`),
expectedCalcCost: 4,
setMaxElements: 3000,
expectedSetCost: 4,
},
{
name: "duration array with all",
schemaGenerator: genArrayWithRule("duration", "self.all(x, true)"),
expectedCalcCost: 2359295,
setMaxElements: 5,
expectedSetCost: 17,
},
{
name: "index of duration array",
schemaGenerator: genArrayWithRule("duration", "self[0].getHours() == 2"),
expectedCalcCost: 4,
setMaxElements: 525,
expectedSetCost: 4,
},
{
name: "date array with all",
schemaGenerator: genArrayWithRule("date", "self.all(x, true)"),
expectedCalcCost: 725936,
setMaxElements: 15,
expectedSetCost: 47,
},
{
name: "index of date array",
schemaGenerator: genArrayWithRule("date", "self[2].getDayOfMonth() == 13"),
expectedCalcCost: 4,
setMaxElements: 42,
expectedSetCost: 4,
},
{
name: "date-time array with all",
schemaGenerator: genArrayWithRule("date-time", "self.all(x, true)"),
expectedCalcCost: 428963,
setMaxElements: 25,
expectedSetCost: 77,
},
{
name: "index of date-time array",
schemaGenerator: genArrayWithRule("date-time", "self[2].getMinutes() == 45"),
expectedCalcCost: 4,
setMaxElements: 99,
expectedSetCost: 4,
},
{
name: "map of int-or-strings with all",
schemaGenerator: genMapWithRule("int-or-string", "self.all(x, true)"),
expectedCalcCost: 1348169,
setMaxElements: 15,
expectedSetCost: 47,
},
{
name: "map of int-or-strings with has",
schemaGenerator: genMapWithRule("int-or-string", "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 5000,
expectedSetCost: 0,
},
{
name: "map of objects with all",
schemaGenerator: genMapWithCustomItemRule(genObjectForMap(), "self.all(x, true)"),
expectedCalcCost: 428963,
setMaxElements: 20,
expectedSetCost: 62,
},
{
name: "map of objects with has",
schemaGenerator: genMapWithCustomItemRule(genObjectForMap(), "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 9001,
expectedSetCost: 0,
},
{
name: "map of number maps with all",
schemaGenerator: genMapWithCustomItemRule(genMapForMap(), "self.all(x, true)"),
expectedCalcCost: 1179647,
setMaxElements: 10,
expectedSetCost: 32,
},
{
name: "map of number maps with has",
schemaGenerator: genMapWithCustomItemRule(genMapForMap(), "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 101,
expectedSetCost: 0,
},
{
name: "map of number arrays with all",
schemaGenerator: genMapWithCustomItemRule(genArrayForMap(), "self.all(x, true)"),
expectedCalcCost: 1179647,
setMaxElements: 25,
expectedSetCost: 77,
},
{
name: "map of number arrays with has",
schemaGenerator: genMapWithCustomItemRule(genArrayForMap(), "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 40000,
expectedSetCost: 0,
},
{
name: "map of durations with all",
schemaGenerator: genMapWithRule("duration", "self.all(x, true)"),
expectedCalcCost: 1048577,
setMaxElements: 5,
expectedSetCost: 17,
},
{
name: "map of durations with has",
schemaGenerator: genMapWithRule("duration", "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 256,
expectedSetCost: 0,
},
{
name: "map of dates with all",
schemaGenerator: genMapWithRule("date", "self.all(x, true)"),
expectedCalcCost: 524288,
setMaxElements: 10,
expectedSetCost: 32,
},
{
name: "map of dates with has",
schemaGenerator: genMapWithRule("date", "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 65536,
expectedSetCost: 0,
},
{
name: "map of date-times with all",
schemaGenerator: genMapWithRule("date-time", "self.all(x, true)"),
expectedCalcCost: 349526,
setMaxElements: 25,
expectedSetCost: 77,
},
{
name: "map of date-times with has",
schemaGenerator: genMapWithRule("date-time", "has(self.x)"),
expectedCalcCost: 0,
setMaxElements: 490,
expectedSetCost: 0,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
// dynamic maxLength case
schema := testCase.schemaGenerator(nil)
t.Run("calc maxLength", schemaChecker(schema, testCase.expectedCalcCost, testCase.expectCalcCostExceedsLimit, t))
// static maxLength case
setSchema := testCase.schemaGenerator(&testCase.setMaxElements)
t.Run("set maxLength", schemaChecker(setSchema, testCase.expectedSetCost, 0, t))
})
}
}

View File

@ -1682,12 +1682,17 @@ func TestValidationExpressions(t *testing.T) {
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for i := range tests {
i := i
t.Run(tests[i].name, func(t *testing.T) {
t.Parallel()
// set costBudget to maxInt64 for current test
tt := tests[i]
tt.costBudget = math.MaxInt64
for _, validRule := range tt.valid {
for j := range tt.valid {
validRule := tt.valid[j]
t.Run(validRule, func(t *testing.T) {
t.Parallel()
s := withRule(*tt.schema, validRule)
celValidator := NewValidator(&s, PerCallLimit)
if celValidator == nil {

View File

@ -15,9 +15,36 @@
package model
import (
"time"
"github.com/google/cel-go/checker/decls"
"github.com/google/cel-go/common/types"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
)
const (
// the largest request that will be accepted is 3MB
// TODO(DangerOnTheRanger): wire in MaxRequestBodyBytes from apiserver/pkg/server/options/server_run_options.go to make this configurable
maxRequestSizeBytes = int64(3 * 1024 * 1024)
// OpenAPI duration strings follow RFC 3339, section 5.6 - see the comment on maxDatetimeSizeJSON
maxDurationSizeJSON = 32
// OpenAPI datetime strings follow RFC 3339, section 5.6, and the longest possible
// such string is 9999-12-31T23:59:59.999999999Z, which has length 30 - we add 2
// to allow for quotation marks
maxDatetimeSizeJSON = 32
// Golang allows a string of 0 to be parsed as a duration, so that plus 2 to account for
// quotation marks makes 3
minDurationSizeJSON = 3
// RFC 3339 dates require YYYY-MM-DD, and then we add 2 to allow for quotation marks
dateSizeJSON = 12
// RFC 3339 times require 2-digit 24-hour time at the very least plus a capital T at the start,
// e.g., T23, and we add 2 to allow for quotation marks as usual
minTimeSizeJSON = 5
// RFC 3339 datetimes require a full date (YYYY-MM-DD) and full time (HH:MM:SS), and we add 3 for
// quotation marks like always in addition to the capital T that separates the date and time
minDatetimeSizeJSON = 21
)
// SchemaDeclType converts the structural schema to a CEL declaration, or returns nil if the
// the structural schema should not be exposed in CEL expressions.
// Set isResourceRoot to true for the root of a custom resource or embedded resource.
@ -44,7 +71,10 @@ func SchemaDeclType(s *schema.Structural, isResourceRoot bool) *DeclType {
// To validate requirements on both the int and string representation:
// `type(intOrStringField) == int ? intOrStringField < 5 : double(intOrStringField.replace('%', '')) < 0.5
//
return DynType
dyn := newSimpleType("dyn", decls.Dyn, nil)
// handle x-kubernetes-int-or-string by returning the max length of the largest possible string
dyn.MaxElements = maxRequestSizeBytes - 2
return dyn
}
// We ignore XPreserveUnknownFields since we don't support validation rules on
@ -61,8 +91,14 @@ func SchemaDeclType(s *schema.Structural, isResourceRoot bool) *DeclType {
case "array":
if s.Items != nil {
itemsType := SchemaDeclType(s.Items, s.Items.XEmbeddedResource)
var maxItems int64
if s.ValueValidation != nil && s.ValueValidation.MaxItems != nil {
maxItems = *s.ValueValidation.MaxItems
} else {
maxItems = estimateMaxArrayItemsPerRequest(s.Items)
}
if itemsType != nil {
return NewListType(itemsType)
return NewListType(itemsType, maxItems)
}
}
return nil
@ -70,7 +106,13 @@ func SchemaDeclType(s *schema.Structural, isResourceRoot bool) *DeclType {
if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil {
propsType := SchemaDeclType(s.AdditionalProperties.Structural, s.AdditionalProperties.Structural.XEmbeddedResource)
if propsType != nil {
return NewMapType(StringType, propsType)
var maxProperties int64
if s.ValueValidation != nil && s.ValueValidation.MaxProperties != nil {
maxProperties = *s.ValueValidation.MaxProperties
} else {
maxProperties = estimateMaxAdditionalPropertiesPerRequest(s.AdditionalProperties.Structural)
}
return NewMapType(StringType, propsType, maxProperties)
}
return nil
}
@ -106,14 +148,34 @@ func SchemaDeclType(s *schema.Structural, isResourceRoot bool) *DeclType {
if s.ValueValidation != nil {
switch s.ValueValidation.Format {
case "byte":
return BytesType
byteWithMaxLength := newSimpleType("bytes", decls.Bytes, types.Bytes([]byte{}))
if s.ValueValidation.MaxLength != nil {
byteWithMaxLength.MaxElements = *s.ValueValidation.MaxLength
} else {
byteWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s)
}
return byteWithMaxLength
case "duration":
return DurationType
durationWithMaxLength := newSimpleType("duration", decls.Duration, types.Duration{Duration: time.Duration(0)})
durationWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s)
return durationWithMaxLength
case "date", "date-time":
return TimestampType
timestampWithMaxLength := newSimpleType("timestamp", decls.Timestamp, types.Timestamp{Time: time.Time{}})
timestampWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s)
return timestampWithMaxLength
}
}
return StringType
strWithMaxLength := newSimpleType("string", decls.String, types.String(""))
if s.ValueValidation != nil && s.ValueValidation.MaxLength != nil {
// multiply the user-provided max length by 4 in the case of an otherwise-untyped string
// we do this because the OpenAPIv3 spec indicates that maxLength is specified in runes/code points,
// but we need to reason about length for things like request size, so we use bytes in this code (and an individual
// unicode code point can be up to 4 bytes long)
strWithMaxLength.MaxElements = *s.ValueValidation.MaxLength * 4
} else {
strWithMaxLength.MaxElements = estimateMaxStringLengthPerRequest(s)
}
return strWithMaxLength
case "boolean":
return BoolType
case "number":
@ -137,8 +199,8 @@ func WithTypeAndObjectMeta(s *schema.Structural) *schema.Structural {
return s
}
result := &schema.Structural{
Generic: s.Generic,
Extensions: s.Extensions,
Generic: s.Generic,
Extensions: s.Extensions,
ValueValidation: s.ValueValidation,
}
props := make(map[string]schema.Structural, len(s.Properties))
@ -151,11 +213,107 @@ func WithTypeAndObjectMeta(s *schema.Structural) *schema.Structural {
props["metadata"] = schema.Structural{
Generic: schema.Generic{Type: "object"},
Properties: map[string]schema.Structural{
"name": stringType,
"name": stringType,
"generateName": stringType,
},
}
result.Properties = props
return result
}
}
// estimateMinSizeJSON estimates the minimum size in bytes of the given schema when serialized in JSON.
// minLength/minProperties/minItems are not currently taken into account, so if these limits are set the
// minimum size might be higher than what estimateMinSizeJSON returns.
func estimateMinSizeJSON(s *schema.Structural) int64 {
if s == nil {
// minimum valid JSON token has length 1 (single-digit number like `0`)
return 1
}
switch s.Type {
case "boolean":
// true
return 4
case "number", "integer":
// 0
return 1
case "string":
if s.ValueValidation != nil {
switch s.ValueValidation.Format {
case "duration":
return minDurationSizeJSON
case "date":
return dateSizeJSON
case "date-time":
return minDatetimeSizeJSON
}
}
// ""
return 2
case "array":
// []
return 2
case "object":
// {}
objSize := int64(2)
// exclude optional fields since the request can omit them
if s.ValueValidation != nil {
for _, propName := range s.ValueValidation.Required {
if prop, ok := s.Properties[propName]; ok {
if prop.Default.Object != nil {
// exclude fields with a default, those are filled in server-side
continue
}
// add 4, 2 for quotations around the property name, 1 for the colon, and 1 for a comma
objSize += int64(len(propName)) + estimateMinSizeJSON(&prop) + 4
}
}
}
return objSize
}
if s.XIntOrString {
// 0
return 1
}
// this code should be unreachable, so return the safest possible value considering this can be used as
// a divisor
return 1
}
// estimateMaxArrayItemsPerRequest estimates the maximum number of array items with
// the provided schema that can fit into a single request.
func estimateMaxArrayItemsPerRequest(itemSchema *schema.Structural) int64 {
// subtract 2 to account for [ and ]
return (maxRequestSizeBytes - 2) / (estimateMinSizeJSON(itemSchema) + 1)
}
// estimateMaxStringLengthPerRequest estimates the maximum string length (in characters)
// of a string compatible with the format requirements in the provided schema.
// must only be called on schemas of type "string" or x-kubernetes-int-or-string: true
func estimateMaxStringLengthPerRequest(s *schema.Structural) int64 {
if s.ValueValidation == nil || s.XIntOrString {
// subtract 2 to account for ""
return (maxRequestSizeBytes - 2)
}
switch s.ValueValidation.Format {
case "duration":
return maxDurationSizeJSON
case "date":
return dateSizeJSON
case "date-time":
return maxDatetimeSizeJSON
default:
// subtract 2 to account for ""
return (maxRequestSizeBytes - 2)
}
}
// estimateMaxAdditionalPropertiesPerRequest estimates the maximum number of additional properties
// with the provided schema that can fit into a single request.
func estimateMaxAdditionalPropertiesPerRequest(additionalPropertiesSchema *schema.Structural) int64 {
// 2 bytes for key + "" + colon + comma + smallest possible value, realistically the actual keys
// will all vary in length
keyValuePairSize := estimateMinSizeJSON(additionalPropertiesSchema) + 6
// subtract 2 to account for { and }
return (maxRequestSizeBytes - 2) / keyValuePairSize
}

View File

@ -238,3 +238,267 @@ func testSchema() *schema.Structural {
}
return ts
}
func arraySchema(arrayType, format string, maxItems *int64) *schema.Structural {
return &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: arrayType,
},
ValueValidation: &schema.ValueValidation{
Format: format,
},
},
ValueValidation: &schema.ValueValidation{
MaxItems: maxItems,
},
}
}
func TestEstimateMaxLengthJSON(t *testing.T) {
type maxLengthTest struct {
Name string
InputSchema *schema.Structural
ExpectedMaxElements int64
}
tests := []maxLengthTest{
{
Name: "booleanArray",
InputSchema: arraySchema("boolean", "", nil),
// expected JSON is [true,true,...], so our length should be (maxRequestSizeBytes - 2) / 5
ExpectedMaxElements: 629145,
},
{
Name: "durationArray",
InputSchema: arraySchema("string", "duration", nil),
// expected JSON is ["0","0",...] so our length should be (maxRequestSizeBytes - 2) / 4
ExpectedMaxElements: 786431,
},
{
Name: "datetimeArray",
InputSchema: arraySchema("string", "date-time", nil),
// expected JSON is ["2000-01-01T01:01:01","2000-01-01T01:01:01",...] so our length should be (maxRequestSizeBytes - 2) / 22
ExpectedMaxElements: 142987,
},
{
Name: "dateArray",
InputSchema: arraySchema("string", "date", nil),
// expected JSON is ["2000-01-01","2000-01-02",...] so our length should be (maxRequestSizeBytes - 2) / 13
ExpectedMaxElements: 241978,
},
{
Name: "numberArray",
InputSchema: arraySchema("integer", "", nil),
// expected JSON is [0,0,...] so our length should be (maxRequestSizeBytes - 2) / 2
ExpectedMaxElements: 1572863,
},
{
Name: "stringArray",
InputSchema: arraySchema("string", "", nil),
// expected JSON is ["","",...] so our length should be (maxRequestSizeBytes - 2) / 3
ExpectedMaxElements: 1048575,
},
{
Name: "stringMap",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
}},
},
},
// expected JSON is {"":"","":"",...} so our length should be (3000000 - 2) / 6
ExpectedMaxElements: 393215,
},
{
Name: "objectOptionalPropertyArray",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: "object",
},
Properties: map[string]schema.Structural{
"required": schema.Structural{
Generic: schema.Generic{
Type: "string",
},
},
"optional": schema.Structural{
Generic: schema.Generic{
Type: "string",
},
},
},
ValueValidation: &schema.ValueValidation{
Required: []string{"required"},
},
},
},
// expected JSON is [{"required":"",},{"required":"",},...] so our length should be (maxRequestSizeBytes - 2) / 17
ExpectedMaxElements: 185042,
},
{
Name: "arrayWithLength",
InputSchema: arraySchema("integer", "int64", maxPtr(10)),
// manually set by MaxItems
ExpectedMaxElements: 10,
},
{
Name: "stringWithLength",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
MaxLength: maxPtr(20),
},
},
// manually set by MaxLength, but we expect a 4x multiplier compared to the original input
// since OpenAPIv3 maxLength uses code points, but DeclType works with bytes
ExpectedMaxElements: 80,
},
{
Name: "mapWithLength",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "object",
AdditionalProperties: &schema.StructuralOrBool{Structural: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
}},
},
ValueValidation: &schema.ValueValidation{
Format: "string",
MaxProperties: maxPtr(15),
},
},
// manually set by MaxProperties
ExpectedMaxElements: 15,
},
{
Name: "durationMaxSize",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
Format: "duration",
},
},
// should be exactly equal to maxDurationSizeJSON
ExpectedMaxElements: maxDurationSizeJSON,
},
{
Name: "dateSize",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
Format: "date",
},
},
// should be exactly equal to dateSizeJSON
ExpectedMaxElements: dateSizeJSON,
},
{
Name: "maxdatetimeSize",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
Format: "date-time",
},
},
// should be exactly equal to maxDatetimeSizeJSON
ExpectedMaxElements: maxDatetimeSizeJSON,
},
{
Name: "maxintOrStringSize",
InputSchema: &schema.Structural{
Extensions: schema.Extensions{
XIntOrString: true,
},
},
// should be exactly equal to maxRequestSizeBytes - 2 (to allow for quotes in the case of a string)
ExpectedMaxElements: maxRequestSizeBytes - 2,
},
{
Name: "objectDefaultFieldArray",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "array",
},
Items: &schema.Structural{
Generic: schema.Generic{
Type: "object",
},
Properties: map[string]schema.Structural{
"field": schema.Structural{
Generic: schema.Generic{
Type: "string",
Default: schema.JSON{Object: "default"},
},
},
},
ValueValidation: &schema.ValueValidation{
Required: []string{"field"},
},
},
},
// expected JSON is [{},{},...] so our length should be (maxRequestSizeBytes - 2) / 3
ExpectedMaxElements: 1048575,
},
{
Name: "byteStringSize",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
Format: "byte",
},
},
// expected JSON is "" so our length should be (maxRequestSizeBytes - 2)
ExpectedMaxElements: 3145726,
},
{
Name: "byteStringSetMaxLength",
InputSchema: &schema.Structural{
Generic: schema.Generic{
Type: "string",
},
ValueValidation: &schema.ValueValidation{
Format: "byte",
MaxLength: maxPtr(20),
},
},
// note that unlike regular strings we don't have to take unicode into account,
// so we we expect the max length to be exactly equal to the user-supplied one
ExpectedMaxElements: 20,
},
}
for _, testCase := range tests {
t.Run(testCase.Name, func(t *testing.T) {
decl := SchemaDeclType(testCase.InputSchema, false)
if decl.MaxElements != testCase.ExpectedMaxElements {
t.Errorf("wrong maxElements (got %d, expected %d)", decl.MaxElements, testCase.ExpectedMaxElements)
}
})
}
}
func maxPtr(max int64) *int64 {
return &max
}

View File

@ -16,6 +16,7 @@ package model
import (
"fmt"
"math"
"time"
"github.com/google/cel-go/cel"
@ -30,22 +31,28 @@ import (
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
)
const (
noMaxLength = math.MaxInt
)
// NewListType returns a parameterized list type with a specified element type.
func NewListType(elem *DeclType) *DeclType {
func NewListType(elem *DeclType, maxItems int64) *DeclType {
return &DeclType{
name: "list",
ElemType: elem,
MaxElements: maxItems,
exprType: decls.NewListType(elem.ExprType()),
defaultValue: NewListValue(),
}
}
// NewMapType returns a parameterized map type with the given key and element types.
func NewMapType(key, elem *DeclType) *DeclType {
func NewMapType(key, elem *DeclType, maxProperties int64) *DeclType {
return &DeclType{
name: "map",
KeyType: key,
ElemType: elem,
MaxElements: maxProperties,
exprType: decls.NewMapType(key.ExprType(), elem.ExprType()),
defaultValue: NewMapValue(),
}
@ -97,12 +104,13 @@ func newSimpleType(name string, exprType *exprpb.Type, zeroVal ref.Val) *DeclTyp
type DeclType struct {
fmt.Stringer
name string
Fields map[string]*DeclField
KeyType *DeclType
ElemType *DeclType
TypeParam bool
Metadata map[string]string
name string
Fields map[string]*DeclField
KeyType *DeclType
ElemType *DeclType
TypeParam bool
Metadata map[string]string
MaxElements int64
exprType *exprpb.Type
traitMask int
@ -160,7 +168,7 @@ func (t *DeclType) MaybeAssignTypeName(name string) *DeclType {
if updated == t.ElemType {
return t
}
return NewMapType(t.KeyType, updated)
return NewMapType(t.KeyType, updated, t.MaxElements)
}
if t.IsList() {
elemTypeName := fmt.Sprintf("%s.@idx", name)
@ -168,7 +176,7 @@ func (t *DeclType) MaybeAssignTypeName(name string) *DeclType {
if updated == t.ElemType {
return t
}
return NewListType(updated)
return NewListType(updated, t.MaxElements)
}
return t
}
@ -547,8 +555,8 @@ var (
UintType = newSimpleType("uint", decls.Uint, types.Uint(0))
// ListType is equivalent to the CEL 'list' type.
ListType = NewListType(AnyType)
ListType = NewListType(AnyType, noMaxLength)
// MapType is equivalent to the CEL 'map' type.
MapType = NewMapType(AnyType, AnyType)
MapType = NewMapType(AnyType, AnyType, noMaxLength)
)

View File

@ -24,7 +24,7 @@ import (
)
func TestTypes_ListType(t *testing.T) {
list := NewListType(StringType)
list := NewListType(StringType, -1)
if !list.IsList() {
t.Error("list type not identifiable as list")
}
@ -43,7 +43,7 @@ func TestTypes_ListType(t *testing.T) {
}
func TestTypes_MapType(t *testing.T) {
mp := NewMapType(StringType, IntType)
mp := NewMapType(StringType, IntType, -1)
if !mp.IsMap() {
t.Error("map type not identifiable as map")
}

View File

@ -359,6 +359,9 @@ func NewConfig(codecs serializer.CodecFactory) *Config {
// A request body might be encoded in json, and is converted to
// proto when persisted in etcd, so we allow 2x as the largest request
// body size to be accepted and decoded in a write request.
// If this constant is changed, maxRequestSizeBytes in apiextensions-apiserver/third_party/forked/celopenapi/model/schemas.go
// should be changed to reflect the new value, if the two haven't
// been wired together already somehow.
MaxRequestBodyBytes: int64(3 * 1024 * 1024),
// Default to treating watch as a long-running operation