-
Notifications
You must be signed in to change notification settings - Fork 92
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #858 from fluxcd/custom-healthchecks-impl
[RFC-0009] Add CEL library with custom healthchecks to runtime
- Loading branch information
Showing
15 changed files
with
1,758 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
/* | ||
Copyright 2025 The Flux 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. | ||
*/ | ||
|
||
// cel provides utilities for evaluating Common Expression Language (CEL) expressions. | ||
package cel |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
/* | ||
Copyright 2025 The Flux 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" | ||
"fmt" | ||
|
||
"github.com/google/cel-go/cel" | ||
"github.com/google/cel-go/common/types" | ||
"github.com/google/cel-go/ext" | ||
) | ||
|
||
// Expression represents a parsed CEL expression. | ||
type Expression struct { | ||
expr string | ||
prog cel.Program | ||
} | ||
|
||
// Option is a function that configures the CEL expression. | ||
type Option func(*options) | ||
|
||
type options struct { | ||
variables []cel.EnvOption | ||
compile bool | ||
outputType *cel.Type | ||
} | ||
|
||
// WithStructVariables declares variables of type google.protobuf.Struct. | ||
func WithStructVariables(vars ...string) Option { | ||
return func(o *options) { | ||
for _, v := range vars { | ||
d := cel.Variable(v, cel.ObjectType("google.protobuf.Struct")) | ||
o.variables = append(o.variables, d) | ||
} | ||
} | ||
} | ||
|
||
// WithCompile specifies that the expression should be compiled, | ||
// which provides stricter checks at parse time, before evaluation. | ||
func WithCompile() Option { | ||
return func(o *options) { | ||
o.compile = true | ||
} | ||
} | ||
|
||
// WithOutputType specifies the expected output type of the expression. | ||
func WithOutputType(t *cel.Type) Option { | ||
return func(o *options) { | ||
o.outputType = t | ||
} | ||
} | ||
|
||
// NewExpression parses the given CEL expression and returns a new Expression. | ||
func NewExpression(expr string, opts ...Option) (*Expression, error) { | ||
var o options | ||
for _, opt := range opts { | ||
opt(&o) | ||
} | ||
|
||
if !o.compile && (o.outputType != nil || len(o.variables) > 0) { | ||
return nil, fmt.Errorf("output type and variables can only be set when compiling the expression") | ||
} | ||
|
||
envOpts := append([]cel.EnvOption{ | ||
cel.HomogeneousAggregateLiterals(), | ||
cel.EagerlyValidateDeclarations(true), | ||
cel.DefaultUTCTimeZone(true), | ||
cel.CrossTypeNumericComparisons(true), | ||
cel.OptionalTypes(), | ||
ext.Strings(), | ||
ext.Sets(), | ||
ext.Encoders(), | ||
}, o.variables...) | ||
|
||
env, err := cel.NewEnv(envOpts...) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to create CEL environment: %w", err) | ||
} | ||
|
||
parse := env.Parse | ||
if o.compile { | ||
parse = env.Compile | ||
} | ||
e, issues := parse(expr) | ||
if issues != nil { | ||
return nil, fmt.Errorf("failed to parse the CEL expression '%s': %s", expr, issues.String()) | ||
} | ||
|
||
if w, g := o.outputType, e.OutputType(); w != nil && w != g { | ||
return nil, fmt.Errorf("CEL expression output type mismatch: expected %s, got %s", w, g) | ||
} | ||
|
||
progOpts := []cel.ProgramOption{ | ||
cel.EvalOptions(cel.OptOptimize), | ||
|
||
// 100 is the kubernetes default: | ||
// https://github.com/kubernetes/kubernetes/blob/3f26d005571dc5903e7cebae33ada67986bc40f3/staging/src/k8s.io/apiserver/pkg/apis/cel/config.go#L33-L35 | ||
cel.InterruptCheckFrequency(100), | ||
} | ||
|
||
prog, err := env.Program(e, progOpts...) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to create CEL program: %w", err) | ||
} | ||
|
||
return &Expression{ | ||
expr: expr, | ||
prog: prog, | ||
}, nil | ||
} | ||
|
||
// EvaluateBoolean evaluates the expression with the given data and returns the result as a boolean. | ||
func (e *Expression) EvaluateBoolean(ctx context.Context, data map[string]any) (bool, error) { | ||
val, _, err := e.prog.ContextEval(ctx, data) | ||
if err != nil { | ||
return false, fmt.Errorf("failed to evaluate the CEL expression '%s': %w", e.expr, err) | ||
} | ||
result, ok := val.(types.Bool) | ||
if !ok { | ||
return false, fmt.Errorf("failed to evaluate CEL expression as boolean: '%s'", e.expr) | ||
} | ||
return bool(result), nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,221 @@ | ||
/* | ||
Copyright 2025 The Flux 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_test | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
|
||
celgo "github.com/google/cel-go/cel" | ||
. "github.com/onsi/gomega" | ||
|
||
"github.com/fluxcd/pkg/runtime/cel" | ||
) | ||
|
||
func TestNewExpression(t *testing.T) { | ||
for _, tt := range []struct { | ||
name string | ||
expr string | ||
opts []cel.Option | ||
err string | ||
}{ | ||
{ | ||
name: "valid expression", | ||
expr: "foo", | ||
}, | ||
{ | ||
name: "invalid expression", | ||
expr: "foo.", | ||
err: "failed to parse the CEL expression 'foo.': ERROR: <input>:1:5: Syntax error: no viable alternative at input '.'", | ||
}, | ||
{ | ||
name: "compilation detects undeclared references", | ||
expr: "foo", | ||
opts: []cel.Option{cel.WithCompile()}, | ||
err: "failed to parse the CEL expression 'foo': ERROR: <input>:1:1: undeclared reference to 'foo'", | ||
}, | ||
{ | ||
name: "compilation detects type errors", | ||
expr: "foo == 'bar'", | ||
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, | ||
err: "failed to parse the CEL expression 'foo == 'bar'': ERROR: <input>:1:5: found no matching overload for '_==_' applied to '(map(string, dyn), string)'", | ||
}, | ||
{ | ||
name: "can't check output type without compiling", | ||
expr: "foo", | ||
opts: []cel.Option{cel.WithOutputType(celgo.BoolType)}, | ||
err: "output type and variables can only be set when compiling the expression", | ||
}, | ||
{ | ||
name: "can't declare variables without compiling", | ||
expr: "foo", | ||
opts: []cel.Option{cel.WithStructVariables("foo")}, | ||
err: "output type and variables can only be set when compiling the expression", | ||
}, | ||
{ | ||
name: "compilation checks output type", | ||
expr: "'foo'", | ||
opts: []cel.Option{cel.WithCompile(), cel.WithOutputType(celgo.BoolType)}, | ||
err: "CEL expression output type mismatch: expected bool, got string", | ||
}, | ||
{ | ||
name: "compilation checking output type can't predict type of struct field", | ||
expr: "foo.bar.baz", | ||
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo"), cel.WithOutputType(celgo.BoolType)}, | ||
err: "CEL expression output type mismatch: expected bool, got dyn", | ||
}, | ||
{ | ||
name: "compilation checking output type can't predict type of struct field, but if it's a boolean it can be compared to a boolean literal", | ||
expr: "foo.bar.baz == true", | ||
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo"), cel.WithOutputType(celgo.BoolType)}, | ||
}, | ||
} { | ||
t.Run(tt.name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
g := NewWithT(t) | ||
|
||
e, err := cel.NewExpression(tt.expr, tt.opts...) | ||
|
||
if tt.err != "" { | ||
g.Expect(err).To(HaveOccurred()) | ||
g.Expect(err.Error()).To(ContainSubstring(tt.err)) | ||
g.Expect(e).To(BeNil()) | ||
} else { | ||
g.Expect(err).NotTo(HaveOccurred()) | ||
g.Expect(e).NotTo(BeNil()) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestExpression_EvaluateBoolean(t *testing.T) { | ||
for _, tt := range []struct { | ||
name string | ||
expr string | ||
opts []cel.Option | ||
data map[string]any | ||
result bool | ||
err string | ||
}{ | ||
{ | ||
name: "inexistent field", | ||
expr: "foo", | ||
data: map[string]any{}, | ||
err: "failed to evaluate the CEL expression 'foo': no such attribute(s): foo", | ||
}, | ||
{ | ||
name: "boolean field true", | ||
expr: "foo", | ||
data: map[string]any{"foo": true}, | ||
result: true, | ||
}, | ||
{ | ||
name: "boolean field false", | ||
expr: "foo", | ||
data: map[string]any{"foo": false}, | ||
result: false, | ||
}, | ||
{ | ||
name: "nested boolean field true", | ||
expr: "foo.bar", | ||
data: map[string]any{"foo": map[string]any{"bar": true}}, | ||
result: true, | ||
}, | ||
{ | ||
name: "nested boolean field false", | ||
expr: "foo.bar", | ||
data: map[string]any{"foo": map[string]any{"bar": false}}, | ||
result: false, | ||
}, | ||
{ | ||
name: "boolean literal true", | ||
expr: "true", | ||
data: map[string]any{}, | ||
result: true, | ||
}, | ||
{ | ||
name: "boolean literal false", | ||
expr: "false", | ||
data: map[string]any{}, | ||
result: false, | ||
}, | ||
{ | ||
name: "non-boolean literal", | ||
expr: "'some-value'", | ||
data: map[string]any{}, | ||
err: "failed to evaluate CEL expression as boolean: ''some-value''", | ||
}, | ||
{ | ||
name: "non-boolean field", | ||
expr: "foo", | ||
data: map[string]any{"foo": "some-value"}, | ||
err: "failed to evaluate CEL expression as boolean: 'foo'", | ||
}, | ||
{ | ||
name: "nested non-boolean field", | ||
expr: "foo.bar", | ||
data: map[string]any{"foo": map[string]any{"bar": "some-value"}}, | ||
err: "failed to evaluate CEL expression as boolean: 'foo.bar'", | ||
}, | ||
{ | ||
name: "complex expression evaluating true", | ||
expr: "foo && bar", | ||
data: map[string]any{"foo": true, "bar": true}, | ||
result: true, | ||
}, | ||
{ | ||
name: "complex expression evaluating false", | ||
expr: "foo && bar", | ||
data: map[string]any{"foo": true, "bar": false}, | ||
result: false, | ||
}, | ||
{ | ||
name: "compiled expression returning true", | ||
expr: "foo.bar", | ||
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, | ||
data: map[string]any{"foo": map[string]any{"bar": true}}, | ||
result: true, | ||
}, | ||
{ | ||
name: "compiled expression returning false", | ||
expr: "foo.bar", | ||
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")}, | ||
data: map[string]any{"foo": map[string]any{"bar": false}}, | ||
result: false, | ||
}, | ||
} { | ||
t.Run(tt.name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
g := NewWithT(t) | ||
|
||
e, err := cel.NewExpression(tt.expr, tt.opts...) | ||
g.Expect(err).NotTo(HaveOccurred()) | ||
|
||
result, err := e.EvaluateBoolean(context.Background(), tt.data) | ||
|
||
if tt.err != "" { | ||
g.Expect(err).To(HaveOccurred()) | ||
g.Expect(err.Error()).To(ContainSubstring(tt.err)) | ||
} else { | ||
g.Expect(err).NotTo(HaveOccurred()) | ||
g.Expect(result).To(Equal(tt.result)) | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.