Skip to content

Commit

Permalink
support cel selector
Browse files Browse the repository at this point in the history
Signed-off-by: Qing Hao <[email protected]>
  • Loading branch information
haoqing0110 committed Dec 23, 2024
1 parent 29bf254 commit fc97656
Show file tree
Hide file tree
Showing 8 changed files with 596 additions and 0 deletions.
13 changes: 13 additions & 0 deletions pkg/placement/controllers/scheduling/scheduling_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -790,11 +790,24 @@ func filterClustersBySelector(
return matched, status
}

// cel evaluator
evaluator, err := helpers.NewEvaluator()
if err != nil {
panic(err)
}

// filter clusters by label selector
for _, cluster := range clusters {
if ok := clusterSelector.Matches(cluster.Labels, helpers.GetClusterClaims(cluster)); !ok {
continue
}
if ok, err := evaluator.Evaluate(cluster, selector.CelSelector.CelExpressions); !ok {
if err != nil {
status := framework.NewStatus("", framework.Misconfigured, err.Error())
return []clusterapiv1beta1.ClusterDecision{}, status
}
continue
}
if !clusterNames.Has(cluster.Name) {
continue
}
Expand Down
356 changes: 356 additions & 0 deletions pkg/placement/helpers/cel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
package helpers

import (
"fmt"

"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/util/version"
clusterapiv1 "open-cluster-management.io/api/cluster/v1"
clusterapiv1alpha1 "open-cluster-management.io/api/cluster/v1alpha1"

"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
)

// ManagedClusterLib defines the CEL library for ManagedCluster evaluation.
// It provides functions and variables for evaluating ManagedCluster properties
// and their associated resources.
//
// Variables:
//
// managedCluster
//
// Provides access to ManagedCluster properties.
//
// Functions:
//
// scores
//
// Returns a list of AddOnPlacementScoreItem for a given cluster and AddOnPlacementScore resource name.
//
// scores(<ManagedCluster>, <string>) <list>
//
// The returned list contains maps with the following structure:
// - name: string - The name of the score
// - value: int - The numeric score value
// - quantity: number|string - The quantity value, represented as:
// * number: for pure decimal values (e.g., 3)
// * string: for values with units or decimal places (e.g., "300Mi", "1.5Gi")
//
// Examples:
//
// managedCluster.scores("cpu-memory") // returns [{name: "cpu", value: 3, quantity: 3"}, {name: "memory", value: 4, quantity: "300Mi"}]
//
// Version Comparisons:
//
// versionIsGreaterThan
//
// Returns true if the first version string is greater than the second version string.
// The version must follow Semantic Versioning specification (http://semver.org/).
// It can be with or without 'v' prefix (eg, "1.14.3" or "v1.14.3").
//
// versionIsGreaterThan(<string>, <string>) <bool>
//
// Examples:
//
// versionIsGreaterThan("1.25.0", "1.24.0") // returns true
// versionIsGreaterThan("1.24.0", "1.25.0") // returns false
//
// versionIsLessThan
//
// Returns true if the first version string is less than the second version string.
// The version must follow Semantic Versioning specification (http://semver.org/).
// It can be with or without 'v' prefix (eg, "1.14.3" or "v1.14.3").
//
// versionIsLessThan(<string>, <string>) <bool>
//
// Examples:
//
// versionIsLessThan("1.24.0", "1.25.0") // returns true
// versionIsLessThan("1.25.0", "1.24.0") // returns false
//
// Quantity Comparisons:
//
// quantityIsGreaterThan
//
// Returns true if the first quantity string is greater than the second quantity string.
//
// quantityIsGreaterThan(<string>, <string>) <bool>
//
// Examples:
//
// quantityIsGreaterThan("2Gi", "1Gi") // returns true
// quantityIsGreaterThan("1Gi", "2Gi") // returns false
// quantityIsGreaterThan("1000Mi", "1Gi") // returns false
//
// quantityIsLessThan
//
// Returns true if the first quantity string is less than the second quantity string.
//
// quantityIsLessThan(<string>, <string>) <bool>
//
// Examples:
//
// quantityIsLessThan("1Gi", "2Gi") // returns true
// quantityIsLessThan("2Gi", "1Gi") // returns false
// quantityIsLessThan("1000Mi", "1Gi") // returns true

type ManagedClusterLib struct{}

// CompileOptions implements cel.Library interface to provide compile-time options.
func (ManagedClusterLib) CompileOptions() []cel.EnvOption {
return []cel.EnvOption{
// The input types may either be instances of `proto.Message` or `ref.Type`.
// Here we use func ConvertManagedCluster() to convert ManagedCluster to a Map.
cel.Variable("managedCluster", cel.MapType(cel.StringType, cel.DynType)),

cel.Function("scores",
cel.MemberOverload(
"cluster_scores",
[]*cel.Type{cel.DynType, cel.StringType},
cel.ListType(cel.DynType),
cel.FunctionBinding(clusterScores)),
),

cel.Function("versionIsGreaterThan",
cel.MemberOverload(
"version_is_greater_than",
[]*cel.Type{cel.StringType, cel.StringType},
cel.BoolType,
cel.FunctionBinding(versionIsGreaterThan)),
),

cel.Function("versionIsLessThan",
cel.MemberOverload(
"version_is_less_than",
[]*cel.Type{cel.StringType, cel.StringType},
cel.BoolType,
cel.FunctionBinding(versionIsLessThan)),
),

cel.Function("quantityIsGreaterThan",
cel.MemberOverload(
"quantity_is_greater_than",
[]*cel.Type{cel.StringType, cel.StringType},
cel.BoolType,
cel.FunctionBinding(quantityIsGreaterThan)),
),

cel.Function("quantityIsLessThan",
cel.MemberOverload(
"quantity_is_less_than",
[]*cel.Type{cel.StringType, cel.StringType},
cel.BoolType,
cel.FunctionBinding(quantityIsLessThan)),
),
}
}

// ProgramOptions implements cel.Library interface to provide runtime options.
// You can use this to add custom functions or evaluators.
func (ManagedClusterLib) ProgramOptions() []cel.ProgramOption {
return nil
}

// Evaluator is a reusable struct for CEL evaluation on ManagedCluster.
type Evaluator struct {
env *cel.Env
}

// NewEvaluator creates a new CEL Evaluator for ManagedCluster objects.
func NewEvaluator() (*Evaluator, error) {
env, err := cel.NewEnv(
cel.Lib(ManagedClusterLib{}), // Add the ManagedClusterLib to the CEL environment
)
if err != nil {
return nil, fmt.Errorf("failed to create CEL environment: %w", err)
}
return &Evaluator{env: env}, nil
}

// Evaluate evaluates a CEL expression against a ManagedCluster.
func (e *Evaluator) Evaluate(cluster *clusterapiv1.ManagedCluster, expressions []string) (bool, error) {
convertedCluster := convertManagedCluster(cluster)

for _, expr := range expressions {
ast, iss := e.env.Compile(expr)
if iss.Err() != nil {
return false, fmt.Errorf("failed to compile CEL expression '%s': %w", expr, iss.Err())
}

prg, _ := e.env.Program(ast)
result, _, err := prg.Eval(map[string]interface{}{
"managedCluster": convertedCluster,
})
if err != nil {
return false, fmt.Errorf("CEL evaluation error: %w", err)
}

if value, ok := result.Value().(bool); !ok || !value {
return false, nil
}
}

return true, nil
}

// convertManagedCluster converts a ManagedCluster object to a CEL-compatible format.
func convertManagedCluster(cluster *clusterapiv1.ManagedCluster) map[string]interface{} {
convertedClusterClaims := []map[string]interface{}{}
for _, claim := range cluster.Status.ClusterClaims {
convertedClusterClaims = append(convertedClusterClaims, map[string]interface{}{
"name": claim.Name,
"value": claim.Value,
})
}

convertedVersion := map[string]interface{}{
"kubernetes": cluster.Status.Version.Kubernetes,
}

// TODO: more fields to add
return map[string]interface{}{
"metadata": map[string]interface{}{
"name": cluster.Name,
"labels": cluster.Labels,
"annotations": cluster.Annotations,
},
"status": map[string]interface{}{
"clusterClaims": convertedClusterClaims,
"version": convertedVersion,
},
}
}

// ScoreToCel converts an AddOnPlacementScoreItem to a CEL-compatible map structure.
// For quantities that are pure integers (e.g., 3), it uses numeric values.
// For quantities with units or decimals (e.g., "300Mi", "1.5Gi"), it uses string representation.
func ScoreToCel(name string, value int32, q resource.Quantity) ref.Val {
var quantityValue interface{}
if q.Format == resource.DecimalSI && q.MilliValue()%1000 == 0 {
quantityValue = q.Value()
} else {
quantityValue = q.String()
}

return types.NewStringInterfaceMap(types.DefaultTypeAdapter, map[string]interface{}{
"name": name,
"value": value,
"quantity": quantityValue,
})
}

// clusterScores implements the CEL function scores(cluster, scoreName) that returns
// a list of AddOnPlacementScores for the given cluster and score resource name.
// Each score in the returned list contains:
// - name: the score identifier
// - value: the numeric score value
// - quantity: the resource quantity (as number or string)
func clusterScores(args ...ref.Val) ref.Val {
cluster := args[0].(traits.Mapper)
metadata, _ := cluster.Find(types.String("metadata"))
clusterName, _ := metadata.(traits.Mapper).Find(types.String("name"))
scoreName := args[1]
fmt.Sprintf("%s, %s", clusterName, scoreName)

Check failure on line 256 in pkg/placement/helpers/cel.go

View workflow job for this annotation

GitHub Actions / verify

result of fmt.Sprintf call not used

// TODO: Replace with actual score lookup using clusterName and scoreName
scores := []clusterapiv1alpha1.AddOnPlacementScoreItem{
{
Name: "cpu",
Value: 3,
Quantity: resource.MustParse("3"),
},
{
Name: "memory",
Value: 4,
Quantity: resource.MustParse("300Mi"),
},
}

celScores := make([]ref.Val, len(scores))
for i, score := range scores {
celScores[i] = ScoreToCel(score.Name, score.Value, score.Quantity)
}

return types.NewDynamicList(types.DefaultTypeAdapter, celScores)
}

// compareVersions is a helper function that compares two version strings
func compareVersions(v1Str, v2Str ref.Val, op string) ref.Val {
if v1Str == nil || v2Str == nil {
return types.NewErr("%s: requires exactly two arguments", op)
}

// Convert arguments to strings
v1, ok1 := v1Str.Value().(string)
v2, ok2 := v2Str.Value().(string)
if !ok1 || !ok2 {
return types.NewErr("%s: both arguments must be strings", op)
}

// Parse first version
v1Parsed, err := version.ParseSemantic(v1)
if err != nil {
return types.NewErr("%s: invalid first version: %v", op, err)
}

// Compare versions
cmp, err := v1Parsed.Compare(v2)
if err != nil {
return types.NewErr("%s: comparison failed: %v", op, err)
}

if op == "versionIsGreaterThan" {
return types.Bool(cmp > 0)
}
return types.Bool(cmp < 0)
}

func versionIsGreaterThan(args ...ref.Val) ref.Val {
return compareVersions(args[0], args[1], "versionIsGreaterThan")
}

func versionIsLessThan(args ...ref.Val) ref.Val {
return compareVersions(args[0], args[1], "versionIsLessThan")
}

// compareQuantities is a helper function that compares two quantity strings
func compareQuantities(q1Str, q2Str ref.Val, op string) ref.Val {
if q1Str == nil || q2Str == nil {
return types.NewErr("%s: requires exactly two arguments", op)
}

// Convert arguments to strings
q1, ok1 := q1Str.Value().(string)
q2, ok2 := q2Str.Value().(string)
if !ok1 || !ok2 {
return types.NewErr("%s: both arguments must be strings", op)
}

// Parse quantities
q1Val, err := resource.ParseQuantity(q1)
if err != nil {
return types.NewErr("%s: invalid first quantity: %v", op, err)
}

q2Val, err := resource.ParseQuantity(q2)
if err != nil {
return types.NewErr("%s: invalid second quantity: %v", op, err)
}

cmp := q1Val.Cmp(q2Val)
if op == "quantityIsGreaterThan" {
return types.Bool(cmp > 0)
}
return types.Bool(cmp < 0)
}

func quantityIsGreaterThan(args ...ref.Val) ref.Val {
return compareQuantities(args[0], args[1], "quantityIsGreaterThan")
}

func quantityIsLessThan(args ...ref.Val) ref.Val {
return compareQuantities(args[0], args[1], "quantityIsLessThan")
}
Loading

0 comments on commit fc97656

Please sign in to comment.