Files
Ben Perry 377ba25c26
Scorecard supply-chain security / Scorecard analysis (push) Failing after 1m40s
Post / coverage (push) Failing after 35m43s
Post / images (amd64) (push) Failing after 8m36s
Post / images (arm64) (push) Failing after 8m8s
Post / image manifest (push) Has been skipped
Post / trigger clusteradm e2e (push) Has been skipped
Close stale issues and PRs / stale (push) Successful in 48s
Workload conditions (#910)
* Import OCM API changes for workload conditions

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Implement condition rule evaluator

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Evaluate manifest condition rules after apply

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* note to self

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Cleanup

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Return config option if rules are set

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* update api

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Always return an error to inform user about the state of their condition rule

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Condition rule errors should not result in retrying apply

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Test condition rule reconciliation

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Return condition status Unknown when an internal CEL error occurs

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update api

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Switch to common CEL lib

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update to simplified celExpressions format

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Formatting

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* tidy

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update ocm api

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update sdk-go

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Switch to sdk-go ConditionLib

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update API

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Switch to WellKnownConditions with required Condition field

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Support CEL evaluation budget

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update sdk-go

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update API

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* lint

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update go.mod

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Tests and comments

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Move condition reader to status controller for more frequent updates

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Ignore missing WellKnownCondition

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Fix test

Signed-off-by: Ben Perry <bhperry94@gmail.com>

* Update condition tests

Signed-off-by: Ben Perry <bhperry94@gmail.com>

---------

Signed-off-by: Ben Perry <bhperry94@gmail.com>
2025-06-11 15:47:35 +00:00

276 lines
8.1 KiB
Go

package conditions
import (
"context"
"fmt"
"reflect"
"slices"
"strings"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types/ref"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
celconfig "k8s.io/apiserver/pkg/apis/cel"
"k8s.io/klog/v2"
workapiv1 "open-cluster-management.io/api/work/v1"
ocmcelcommon "open-cluster-management.io/sdk-go/pkg/cel/common"
ocmcellibrary "open-cluster-management.io/sdk-go/pkg/cel/library"
"open-cluster-management.io/ocm/pkg/common/helpers"
"open-cluster-management.io/ocm/pkg/work/spoke/conditions/rules"
)
var globalCostBudget = int64(celconfig.RuntimeCELCostBudget)
type ConditionReader struct {
wellKnownConditions rules.WellKnownConditionRuleResolver
ruleEnv *cel.Env
messageEnv *cel.Env
}
func NewConditionReader() (*ConditionReader, error) {
ruleEnv, err := celEnv(cel.Variable("object", cel.DynType))
if err != nil {
return nil, err
}
messageEnv, err := celEnv(cel.Variable("object", cel.DynType), cel.Variable("result", cel.BoolType))
if err != nil {
return nil, err
}
return &ConditionReader{
wellKnownConditions: rules.DefaultWellKnownConditionResolver(),
ruleEnv: ruleEnv,
messageEnv: messageEnv,
}, nil
}
func (s *ConditionReader) EvaluateConditions(ctx context.Context, obj *unstructured.Unstructured, rules []workapiv1.ConditionRule) []metav1.Condition {
var conditionResults []metav1.Condition
remainingBudget := globalCostBudget
for _, rule := range rules {
var err error
var condition metav1.Condition
condition, remainingBudget, err = s.GetConditionByRule(ctx, obj, rule, remainingBudget)
if err != nil {
// Errors from rule evaluation are not returned since they don't impact resource apply
// The error is set in condition message for users to handle
klog.Errorf("failed to evaluate condition rule: %s", err.Error())
}
// If type is not set, then it was a WellKnownCondition with no match and should be ignored.
if condition.Type != "" {
conditionResults = append(conditionResults, condition)
}
}
return conditionResults
}
func (s *ConditionReader) GetConditionByRule(
ctx context.Context, obj *unstructured.Unstructured, rule workapiv1.ConditionRule, budget int64,
) (metav1.Condition, int64, error) {
switch rule.Type {
case workapiv1.WellKnownConditionsType:
r := s.wellKnownConditions.GetRuleByKindCondition(obj.GroupVersionKind(), rule.Condition)
if len(r.CelExpressions) == 0 {
return metav1.Condition{}, budget, nil
}
// Check for supported overrides in rule
if rule.Message != "" {
r.Message = rule.Message
}
if rule.MessageExpression != "" {
r.MessageExpression = rule.MessageExpression
}
return s.getConditionByCelRule(ctx, obj, r, budget)
case workapiv1.CelConditionExpressionsType:
return s.getConditionByCelRule(ctx, obj, rule, budget)
default:
err := fmt.Errorf("unrecognized condition rule type %s", rule.Type)
return metav1.Condition{
Type: rule.Condition,
Status: metav1.ConditionUnknown,
Reason: workapiv1.ConditionRuleInvalid,
Message: err.Error(),
}, budget, err
}
}
func (s *ConditionReader) getConditionByCelRule(
ctx context.Context, obj *unstructured.Unstructured, rule workapiv1.ConditionRule, budget int64,
) (metav1.Condition, int64, error) {
var message string
var err error
status, reason, remainingBudget, err := s.evaluateCelExpressions(ctx, obj, rule.CelExpressions, budget)
if err != nil {
message = err.Error()
} else {
message, remainingBudget, err = s.getConditionMessageByRule(ctx, obj, rule, status == metav1.ConditionTrue, remainingBudget)
if err != nil && message == "" {
message = err.Error()
}
}
return metav1.Condition{
Type: rule.Condition,
Status: status,
Message: message,
Reason: reason,
}, remainingBudget, err
}
func (s *ConditionReader) evaluateCelExpressions(
ctx context.Context, obj *unstructured.Unstructured, expressions []string, budget int64,
) (status metav1.ConditionStatus, reason string, remainingBudget int64, err error) {
remainingBudget = budget
estimator := newEstimator()
for _, expression := range expressions {
ast, iss := s.ruleEnv.Compile(expression)
err = iss.Err()
if err != nil {
// A error in compiling the rule gives False condition status by convention
return metav1.ConditionFalse, workapiv1.ConditionRuleExpressionError, remainingBudget, err
}
prg, err := s.ruleEnv.Program(
ast,
cel.CostLimit(celconfig.PerCallLimit),
cel.CostTracking(estimator),
cel.InterruptCheckFrequency(celconfig.CheckFrequency),
)
if err != nil {
// User has no control over this error, so we return Unknown condition status
return metav1.ConditionUnknown, workapiv1.ConditionRuleInternalError, remainingBudget, err
}
out, newBudget, err := evaluate(ctx, prg, remainingBudget, expression, map[string]any{
"object": obj.Object,
})
if err != nil {
// A error in evaluating the rule gives False condition status by convention
return metav1.ConditionFalse, workapiv1.ConditionRuleExpressionError, remainingBudget, err
}
remainingBudget = newBudget
switch result := out.Value().(type) {
case bool:
// If result is false return early
// Otherwise continue checking expressions
if !result {
return metav1.ConditionFalse, workapiv1.ConditionRuleEvaluated, remainingBudget, nil
}
default:
err = fmt.Errorf("expected bool result, got %v", reflect.TypeOf(result))
return metav1.ConditionFalse, workapiv1.ConditionRuleExpressionError, remainingBudget, err
}
}
return metav1.ConditionTrue, workapiv1.ConditionRuleEvaluated, remainingBudget, nil
}
func (s *ConditionReader) getConditionMessageByRule(
ctx context.Context, obj *unstructured.Unstructured, rule workapiv1.ConditionRule, result bool, budget int64,
) (string, int64, error) {
if rule.MessageExpression != "" {
ast, iss := s.messageEnv.Compile(rule.MessageExpression)
err := iss.Err()
if err != nil {
// Trim CEL code snippets out of message
message := strings.Split(iss.String(), "\n | ")[0]
return message, budget, err
}
prg, err := s.messageEnv.Program(ast)
if err != nil {
return "", budget, err
}
out, newBudget, err := evaluate(ctx, prg, budget, rule.MessageExpression, map[string]any{
"object": obj.Object,
"result": result,
})
if err != nil {
return "", newBudget, err
}
budget = newBudget
switch message := out.Value().(type) {
case string:
if message != "" {
return message, budget, nil
}
default:
return "", budget, fmt.Errorf("expected message expression to have a string result, got %v", reflect.TypeOf(message))
}
}
if rule.Message != "" {
return rule.Message, budget, nil
}
if result {
return "Manifest is " + rule.Condition, budget, nil
}
return "Manifest is not " + rule.Condition, budget, nil
}
func celEnv(opts ...cel.EnvOption) (*cel.Env, error) {
opts = slices.Concat(
opts,
ocmcelcommon.BaseEnvOpts,
[]cel.EnvOption{
ocmcellibrary.ConditionsLib(),
},
)
return cel.NewEnv(opts...)
}
func evaluate(
ctx context.Context,
program cel.Program,
budget int64,
expression string,
input any,
) (ref.Val, int64, error) {
logger := klog.FromContext(ctx)
// Evaluate the expression
evalResult, evalDetails, err := program.ContextEval(ctx, input)
// Cost calculation
var remainingBudget = budget
if evalDetails != nil {
ok, rtCost := helpers.CostCalculation(ctx, evalDetails, budget, expression)
if !ok {
costErr := fmt.Errorf("CEL evaluation budget exceeded")
if err != nil {
err = utilerrors.NewAggregate([]error{err, costErr})
} else {
err = costErr
}
return nil, -1, err
}
remainingBudget -= rtCost
}
// Handle evaluation error
if err != nil {
logger.Info("Expression evaluation failed", "rule", expression, "cluster", "err", err)
return nil, remainingBudget, err
}
return evalResult, remainingBudget, nil
}
// newEstimator creates a new cost estimator for CEL expressions.
func newEstimator() *ocmcelcommon.BaseEnvCostEstimator {
return &ocmcelcommon.BaseEnvCostEstimator{
CostEstimator: &ocmcellibrary.CostEstimator{},
}
}