Niels de Vos 107407b44b rebase: update replaced k8s.io modules to v0.33.0
Signed-off-by: Niels de Vos <ndevos@ibm.com>
2025-05-07 21:27:27 +00:00

442 lines
15 KiB
Go

/*
Copyright 2022 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 (
"context"
"errors"
"fmt"
"math"
"reflect"
"strings"
"sync"
"github.com/blang/semver/v4"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
"github.com/google/cel-go/ext"
"k8s.io/utils/ptr"
resourceapi "k8s.io/api/resource/v1beta1"
"k8s.io/apimachinery/pkg/util/version"
celconfig "k8s.io/apiserver/pkg/apis/cel"
apiservercel "k8s.io/apiserver/pkg/cel"
"k8s.io/apiserver/pkg/cel/environment"
"k8s.io/apiserver/pkg/cel/library"
)
const (
deviceVar = "device"
driverVar = "driver"
attributesVar = "attributes"
capacityVar = "capacity"
)
var (
lazyCompilerInit sync.Once
lazyCompiler *compiler
// A variant of AnyType = https://github.com/kubernetes/kubernetes/blob/ec2e0de35a298363872897e5904501b029817af3/staging/src/k8s.io/apiserver/pkg/cel/types.go#L550:
// unknown actual type (could be bool, int, string, etc.) but with a known maximum size.
attributeType = withMaxElements(apiservercel.AnyType, resourceapi.DeviceAttributeMaxValueLength)
// Other strings also have a known maximum size.
domainType = withMaxElements(apiservercel.StringType, resourceapi.DeviceMaxDomainLength)
idType = withMaxElements(apiservercel.StringType, resourceapi.DeviceMaxIDLength)
driverType = withMaxElements(apiservercel.StringType, resourceapi.DriverNameMaxLength)
// Each map is bound by the maximum number of different attributes.
innerAttributesMapType = apiservercel.NewMapType(idType, attributeType, resourceapi.ResourceSliceMaxAttributesAndCapacitiesPerDevice)
outerAttributesMapType = apiservercel.NewMapType(domainType, innerAttributesMapType, resourceapi.ResourceSliceMaxAttributesAndCapacitiesPerDevice)
// Same for capacity.
innerCapacityMapType = apiservercel.NewMapType(idType, apiservercel.QuantityDeclType, resourceapi.ResourceSliceMaxAttributesAndCapacitiesPerDevice)
outerCapacityMapType = apiservercel.NewMapType(domainType, innerCapacityMapType, resourceapi.ResourceSliceMaxAttributesAndCapacitiesPerDevice)
)
func GetCompiler() *compiler {
lazyCompilerInit.Do(func() {
lazyCompiler = newCompiler()
})
return lazyCompiler
}
// CompilationResult represents a compiled expression.
type CompilationResult struct {
Program cel.Program
Error *apiservercel.Error
Expression string
OutputType *cel.Type
Environment *cel.Env
// MaxCost represents the worst-case cost of the compiled MessageExpression in terms of CEL's cost units,
// as used by cel.EstimateCost.
MaxCost uint64
emptyMapVal ref.Val
}
// Device defines the input values for a CEL selector expression.
type Device struct {
// Driver gets used as domain for any attribute which does not already
// have a domain prefix. If set, then it is also made available as a
// string attribute.
Driver string
Attributes map[resourceapi.QualifiedName]resourceapi.DeviceAttribute
Capacity map[resourceapi.QualifiedName]resourceapi.DeviceCapacity
}
type compiler struct {
// deviceType is a definition for the type of the `device` variable.
// This is needed for the cost estimator. Both are currently version-independent.
// If that ever changes, some additional logic might be needed to make
// cost estimates version-dependent.
deviceType *apiservercel.DeclType
envset *environment.EnvSet
}
// Options contains several additional parameters
// for [CompileCELExpression]. All of them have reasonable
// defaults.
type Options struct {
// EnvType allows to override the default environment type [environment.StoredExpressions].
EnvType *environment.Type
// CostLimit allows overriding the default runtime cost limit [resourceapi.CELSelectorExpressionMaxCost].
CostLimit *uint64
// DisableCostEstimation can be set to skip estimating the worst-case CEL cost.
// If disabled or after an error, [CompilationResult.MaxCost] will be set to [math.Uint64].
DisableCostEstimation bool
}
// CompileCELExpression returns a compiled CEL expression. It evaluates to bool.
//
// TODO (https://github.com/kubernetes/kubernetes/issues/125826): validate AST to detect invalid attribute names.
func (c compiler) CompileCELExpression(expression string, options Options) CompilationResult {
resultError := func(errorString string, errType apiservercel.ErrorType) CompilationResult {
return CompilationResult{
Error: &apiservercel.Error{
Type: errType,
Detail: errorString,
},
Expression: expression,
MaxCost: math.MaxUint64,
}
}
env, err := c.envset.Env(ptr.Deref(options.EnvType, environment.StoredExpressions))
if err != nil {
return resultError(fmt.Sprintf("unexpected error loading CEL environment: %v", err), apiservercel.ErrorTypeInternal)
}
ast, issues := env.Compile(expression)
if issues != nil {
return resultError("compilation failed: "+issues.String(), apiservercel.ErrorTypeInvalid)
}
expectedReturnType := cel.BoolType
if ast.OutputType() != expectedReturnType &&
ast.OutputType() != cel.AnyType {
return resultError(fmt.Sprintf("must evaluate to %v or the unknown type, not %v", expectedReturnType.String(), ast.OutputType().String()), apiservercel.ErrorTypeInvalid)
}
_, err = cel.AstToCheckedExpr(ast)
if err != nil {
// should be impossible since env.Compile returned no issues
return resultError("unexpected compilation error: "+err.Error(), apiservercel.ErrorTypeInternal)
}
prog, err := env.Program(ast,
// The Kubernetes CEL base environment sets the VAP limit as runtime cost limit.
// DRA has its own default cost limit and also allows the caller to change that
// limit.
cel.CostLimit(ptr.Deref(options.CostLimit, resourceapi.CELSelectorExpressionMaxCost)),
cel.InterruptCheckFrequency(celconfig.CheckFrequency),
)
if err != nil {
return resultError("program instantiation failed: "+err.Error(), apiservercel.ErrorTypeInternal)
}
compilationResult := CompilationResult{
Program: prog,
Expression: expression,
OutputType: ast.OutputType(),
Environment: env,
emptyMapVal: env.CELTypeAdapter().NativeToValue(map[string]any{}),
MaxCost: math.MaxUint64,
}
if !options.DisableCostEstimation {
// We don't have a SizeEstimator. The potential size of the input (= a
// device) is already declared in the definition of the environment.
estimator := c.newCostEstimator()
costEst, err := env.EstimateCost(ast, estimator)
if err != nil {
compilationResult.Error = &apiservercel.Error{Type: apiservercel.ErrorTypeInternal, Detail: "cost estimation failed: " + err.Error()}
return compilationResult
}
compilationResult.MaxCost = costEst.Max
}
return compilationResult
}
func (c *compiler) newCostEstimator() *library.CostEstimator {
return &library.CostEstimator{SizeEstimator: &sizeEstimator{compiler: c}}
}
// getAttributeValue returns the native representation of the one value that
// should be stored in the attribute, otherwise an error. An error is
// also returned when there is no supported value.
func getAttributeValue(attr resourceapi.DeviceAttribute) (any, error) {
switch {
case attr.IntValue != nil:
return *attr.IntValue, nil
case attr.BoolValue != nil:
return *attr.BoolValue, nil
case attr.StringValue != nil:
return *attr.StringValue, nil
case attr.VersionValue != nil:
v, err := semver.Parse(*attr.VersionValue)
if err != nil {
return nil, fmt.Errorf("parse semantic version: %w", err)
}
return apiservercel.Semver{Version: v}, nil
default:
return nil, errors.New("unsupported attribute value")
}
}
var boolType = reflect.TypeOf(true)
func (c CompilationResult) DeviceMatches(ctx context.Context, input Device) (bool, *cel.EvalDetails, error) {
// TODO (future): avoid building these maps and instead use a proxy
// which wraps the underlying maps and directly looks up values.
attributes := make(map[string]any)
for name, attr := range input.Attributes {
value, err := getAttributeValue(attr)
if err != nil {
return false, nil, fmt.Errorf("attribute %s: %w", name, err)
}
domain, id := parseQualifiedName(name, input.Driver)
if attributes[domain] == nil {
attributes[domain] = make(map[string]any)
}
attributes[domain].(map[string]any)[id] = value
}
capacity := make(map[string]any)
for name, cap := range input.Capacity {
domain, id := parseQualifiedName(name, input.Driver)
if capacity[domain] == nil {
capacity[domain] = make(map[string]apiservercel.Quantity)
}
capacity[domain].(map[string]apiservercel.Quantity)[id] = apiservercel.Quantity{Quantity: &cap.Value}
}
variables := map[string]any{
deviceVar: map[string]any{
driverVar: input.Driver,
attributesVar: newStringInterfaceMapWithDefault(c.Environment.CELTypeAdapter(), attributes, c.emptyMapVal),
capacityVar: newStringInterfaceMapWithDefault(c.Environment.CELTypeAdapter(), capacity, c.emptyMapVal),
},
}
result, details, err := c.Program.ContextEval(ctx, variables)
if err != nil {
return false, details, err
}
resultAny, err := result.ConvertToNative(boolType)
if err != nil {
return false, details, fmt.Errorf("CEL result of type %s could not be converted to bool: %w", result.Type().TypeName(), err)
}
resultBool, ok := resultAny.(bool)
if !ok {
return false, details, fmt.Errorf("CEL native result value should have been a bool, got instead: %T", resultAny)
}
return resultBool, details, nil
}
func newCompiler() *compiler {
envset := environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion(), true /* strictCost */)
field := func(name string, declType *apiservercel.DeclType, required bool) *apiservercel.DeclField {
return apiservercel.NewDeclField(name, declType, required, nil, nil)
}
fields := func(fields ...*apiservercel.DeclField) map[string]*apiservercel.DeclField {
result := make(map[string]*apiservercel.DeclField, len(fields))
for _, f := range fields {
result[f.Name] = f
}
return result
}
deviceType := apiservercel.NewObjectType("kubernetes.DRADevice", fields(
field(driverVar, driverType, true),
field(attributesVar, outerAttributesMapType, true),
field(capacityVar, outerCapacityMapType, true),
))
versioned := []environment.VersionedOptions{
{
IntroducedVersion: version.MajorMinor(1, 31),
EnvOptions: []cel.EnvOption{
cel.Variable(deviceVar, deviceType.CelType()),
// https://pkg.go.dev/github.com/google/cel-go/ext#Bindings
//
// This is useful to simplify attribute lookups because the
// domain only needs to be given once:
//
// cel.bind(dra, device.attributes["dra.example.com"], dra.oneBool && dra.anotherBool)
ext.Bindings(ext.BindingsVersion(0)),
},
DeclTypes: []*apiservercel.DeclType{
deviceType,
},
},
{
IntroducedVersion: version.MajorMinor(1, 31),
// This library has added to base environment of Kubernetes
// in 1.33 at version 1. It will continue to be available for
// use in this environment, but does not need to be included
// directly since it becomes available indirectly via the base
// environment shared across Kubernetes.
// In Kubernetes 1.34, version 1 feature of this library will
// become available, and will be rollback safe to 1.33.
// TODO: In Kubernetes 1.34: Add compile tests that demonstrate that
// `isSemver("v1.0.0", true)` and `semver("v1.0.0", true)` are supported.
RemovedVersion: version.MajorMinor(1, 33),
EnvOptions: []cel.EnvOption{
library.SemverLib(library.SemverVersion(0)),
},
},
}
envset, err := envset.Extend(versioned...)
if err != nil {
panic(fmt.Errorf("internal error building CEL environment: %w", err))
}
return &compiler{envset: envset, deviceType: deviceType}
}
func withMaxElements(in *apiservercel.DeclType, maxElements uint64) *apiservercel.DeclType {
out := *in
out.MaxElements = int64(maxElements)
return &out
}
// parseQualifiedName splits into domain and identified, using the default domain
// if the name does not contain one.
func parseQualifiedName(name resourceapi.QualifiedName, defaultDomain string) (string, string) {
sep := strings.Index(string(name), "/")
if sep == -1 {
return defaultDomain, string(name)
}
return string(name[0:sep]), string(name[sep+1:])
}
// newStringInterfaceMapWithDefault is like
// https://pkg.go.dev/github.com/google/cel-go@v0.20.1/common/types#NewStringInterfaceMap,
// except that looking up an unknown key returns a default value.
func newStringInterfaceMapWithDefault(adapter types.Adapter, value map[string]any, defaultValue ref.Val) traits.Mapper {
return mapper{
Mapper: types.NewStringInterfaceMap(adapter, value),
defaultValue: defaultValue,
}
}
type mapper struct {
traits.Mapper
defaultValue ref.Val
}
// Find wraps the mapper's Find so that a default empty map is returned when
// the lookup did not find the entry.
func (m mapper) Find(key ref.Val) (ref.Val, bool) {
value, found := m.Mapper.Find(key)
if found {
return value, true
}
return m.defaultValue, true
}
// sizeEstimator tells the cost estimator the maximum size of maps or strings accessible through the `device` variable.
// Without this, the maximum string size of e.g. `device.attributes["dra.example.com"].services` would be unknown.
//
// sizeEstimator is derived from the sizeEstimator in k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel.
type sizeEstimator struct {
compiler *compiler
}
func (s *sizeEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate {
path := element.Path()
if len(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
}
// The estimator provides information about the environment's variable(s).
var currentNode *apiservercel.DeclType
switch path[0] {
case deviceVar:
currentNode = s.compiler.deviceType
default:
// Unknown root, shouldn't happen.
return nil
}
// Cut off initial variable from path, it was checked above.
for _, name := range 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 {
// If this is an attribute map, then we know that all elements
// have the same maximum size as set in attributeType, regardless
// of their name.
if currentNode.ElemType == attributeType {
currentNode = attributeType
continue
}
return nil
}
if field.Type == nil {
return nil
}
currentNode = field.Type
}
}
return &checker.SizeEstimate{Min: 0, Max: uint64(currentNode.MaxElements)}
}
func (s *sizeEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate {
return nil
}