Skip to content

Commit 62d235c

Browse files
committed
Add CEL library with custom healthchecks to runtime
Signed-off-by: Matheus Pimenta <[email protected]>
1 parent 243510f commit 62d235c

15 files changed

+1758
-5
lines changed

runtime/cel/doc.go

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// cel provides utilities for evaluating Common Expression Language (CEL) expressions.
18+
package cel

runtime/cel/expression.go

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cel
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
"github.com/google/cel-go/cel"
24+
"github.com/google/cel-go/common/types"
25+
"github.com/google/cel-go/ext"
26+
)
27+
28+
// Expression represents a parsed CEL expression.
29+
type Expression struct {
30+
expr string
31+
prog cel.Program
32+
}
33+
34+
// Option is a function that configures the CEL expression.
35+
type Option func(*options)
36+
37+
type options struct {
38+
variables []cel.EnvOption
39+
compile bool
40+
outputType *cel.Type
41+
}
42+
43+
// WithStructVariables declares variables of type google.protobuf.Struct.
44+
func WithStructVariables(vars ...string) Option {
45+
return func(o *options) {
46+
for _, v := range vars {
47+
d := cel.Variable(v, cel.ObjectType("google.protobuf.Struct"))
48+
o.variables = append(o.variables, d)
49+
}
50+
}
51+
}
52+
53+
// WithCompile specifies that the expression should be compiled,
54+
// which provides stricter checks at parse time, before evaluation.
55+
func WithCompile() Option {
56+
return func(o *options) {
57+
o.compile = true
58+
}
59+
}
60+
61+
// WithOutputType specifies the expected output type of the expression.
62+
func WithOutputType(t *cel.Type) Option {
63+
return func(o *options) {
64+
o.outputType = t
65+
}
66+
}
67+
68+
// NewExpression parses the given CEL expression and returns a new Expression.
69+
func NewExpression(expr string, opts ...Option) (*Expression, error) {
70+
var o options
71+
for _, opt := range opts {
72+
opt(&o)
73+
}
74+
75+
if !o.compile && (o.outputType != nil || len(o.variables) > 0) {
76+
return nil, fmt.Errorf("output type and variables can only be set when compiling the expression")
77+
}
78+
79+
envOpts := append([]cel.EnvOption{
80+
cel.HomogeneousAggregateLiterals(),
81+
cel.EagerlyValidateDeclarations(true),
82+
cel.DefaultUTCTimeZone(true),
83+
cel.CrossTypeNumericComparisons(true),
84+
cel.OptionalTypes(),
85+
ext.Strings(),
86+
ext.Sets(),
87+
ext.Encoders(),
88+
}, o.variables...)
89+
90+
env, err := cel.NewEnv(envOpts...)
91+
if err != nil {
92+
return nil, fmt.Errorf("failed to create CEL environment: %w", err)
93+
}
94+
95+
parse := env.Parse
96+
if o.compile {
97+
parse = env.Compile
98+
}
99+
e, issues := parse(expr)
100+
if issues != nil {
101+
return nil, fmt.Errorf("failed to parse the CEL expression '%s': %s", expr, issues.String())
102+
}
103+
104+
if w, g := o.outputType, e.OutputType(); w != nil && w != g {
105+
return nil, fmt.Errorf("CEL expression output type mismatch: expected %s, got %s", w, g)
106+
}
107+
108+
progOpts := []cel.ProgramOption{
109+
cel.EvalOptions(cel.OptOptimize),
110+
111+
// 100 is the kubernetes default:
112+
// https://github.com/kubernetes/kubernetes/blob/3f26d005571dc5903e7cebae33ada67986bc40f3/staging/src/k8s.io/apiserver/pkg/apis/cel/config.go#L33-L35
113+
cel.InterruptCheckFrequency(100),
114+
}
115+
116+
prog, err := env.Program(e, progOpts...)
117+
if err != nil {
118+
return nil, fmt.Errorf("failed to create CEL program: %w", err)
119+
}
120+
121+
return &Expression{
122+
expr: expr,
123+
prog: prog,
124+
}, nil
125+
}
126+
127+
// EvaluateBoolean evaluates the expression with the given data and returns the result as a boolean.
128+
func (e *Expression) EvaluateBoolean(ctx context.Context, data map[string]any) (bool, error) {
129+
val, _, err := e.prog.ContextEval(ctx, data)
130+
if err != nil {
131+
return false, fmt.Errorf("failed to evaluate the CEL expression '%s': %w", e.expr, err)
132+
}
133+
result, ok := val.(types.Bool)
134+
if !ok {
135+
return false, fmt.Errorf("failed to evaluate CEL expression as boolean: '%s'", e.expr)
136+
}
137+
return bool(result), nil
138+
}

runtime/cel/expression_test.go

+221
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cel_test
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
celgo "github.com/google/cel-go/cel"
24+
. "github.com/onsi/gomega"
25+
26+
"github.com/fluxcd/pkg/runtime/cel"
27+
)
28+
29+
func TestNewExpression(t *testing.T) {
30+
for _, tt := range []struct {
31+
name string
32+
expr string
33+
opts []cel.Option
34+
err string
35+
}{
36+
{
37+
name: "valid expression",
38+
expr: "foo",
39+
},
40+
{
41+
name: "invalid expression",
42+
expr: "foo.",
43+
err: "failed to parse the CEL expression 'foo.': ERROR: <input>:1:5: Syntax error: no viable alternative at input '.'",
44+
},
45+
{
46+
name: "compilation detects undeclared references",
47+
expr: "foo",
48+
opts: []cel.Option{cel.WithCompile()},
49+
err: "failed to parse the CEL expression 'foo': ERROR: <input>:1:1: undeclared reference to 'foo'",
50+
},
51+
{
52+
name: "compilation detects type errors",
53+
expr: "foo == 'bar'",
54+
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")},
55+
err: "failed to parse the CEL expression 'foo == 'bar'': ERROR: <input>:1:5: found no matching overload for '_==_' applied to '(map(string, dyn), string)'",
56+
},
57+
{
58+
name: "can't check output type without compiling",
59+
expr: "foo",
60+
opts: []cel.Option{cel.WithOutputType(celgo.BoolType)},
61+
err: "output type and variables can only be set when compiling the expression",
62+
},
63+
{
64+
name: "can't declare variables without compiling",
65+
expr: "foo",
66+
opts: []cel.Option{cel.WithStructVariables("foo")},
67+
err: "output type and variables can only be set when compiling the expression",
68+
},
69+
{
70+
name: "compilation checks output type",
71+
expr: "'foo'",
72+
opts: []cel.Option{cel.WithCompile(), cel.WithOutputType(celgo.BoolType)},
73+
err: "CEL expression output type mismatch: expected bool, got string",
74+
},
75+
{
76+
name: "compilation checking output type can't predict type of struct field",
77+
expr: "foo.bar.baz",
78+
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo"), cel.WithOutputType(celgo.BoolType)},
79+
err: "CEL expression output type mismatch: expected bool, got dyn",
80+
},
81+
{
82+
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",
83+
expr: "foo.bar.baz == true",
84+
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo"), cel.WithOutputType(celgo.BoolType)},
85+
},
86+
} {
87+
t.Run(tt.name, func(t *testing.T) {
88+
t.Parallel()
89+
90+
g := NewWithT(t)
91+
92+
e, err := cel.NewExpression(tt.expr, tt.opts...)
93+
94+
if tt.err != "" {
95+
g.Expect(err).To(HaveOccurred())
96+
g.Expect(err.Error()).To(ContainSubstring(tt.err))
97+
g.Expect(e).To(BeNil())
98+
} else {
99+
g.Expect(err).NotTo(HaveOccurred())
100+
g.Expect(e).NotTo(BeNil())
101+
}
102+
})
103+
}
104+
}
105+
106+
func TestExpression_EvaluateBoolean(t *testing.T) {
107+
for _, tt := range []struct {
108+
name string
109+
expr string
110+
opts []cel.Option
111+
data map[string]any
112+
result bool
113+
err string
114+
}{
115+
{
116+
name: "inexistent field",
117+
expr: "foo",
118+
data: map[string]any{},
119+
err: "failed to evaluate the CEL expression 'foo': no such attribute(s): foo",
120+
},
121+
{
122+
name: "boolean field true",
123+
expr: "foo",
124+
data: map[string]any{"foo": true},
125+
result: true,
126+
},
127+
{
128+
name: "boolean field false",
129+
expr: "foo",
130+
data: map[string]any{"foo": false},
131+
result: false,
132+
},
133+
{
134+
name: "nested boolean field true",
135+
expr: "foo.bar",
136+
data: map[string]any{"foo": map[string]any{"bar": true}},
137+
result: true,
138+
},
139+
{
140+
name: "nested boolean field false",
141+
expr: "foo.bar",
142+
data: map[string]any{"foo": map[string]any{"bar": false}},
143+
result: false,
144+
},
145+
{
146+
name: "boolean literal true",
147+
expr: "true",
148+
data: map[string]any{},
149+
result: true,
150+
},
151+
{
152+
name: "boolean literal false",
153+
expr: "false",
154+
data: map[string]any{},
155+
result: false,
156+
},
157+
{
158+
name: "non-boolean literal",
159+
expr: "'some-value'",
160+
data: map[string]any{},
161+
err: "failed to evaluate CEL expression as boolean: ''some-value''",
162+
},
163+
{
164+
name: "non-boolean field",
165+
expr: "foo",
166+
data: map[string]any{"foo": "some-value"},
167+
err: "failed to evaluate CEL expression as boolean: 'foo'",
168+
},
169+
{
170+
name: "nested non-boolean field",
171+
expr: "foo.bar",
172+
data: map[string]any{"foo": map[string]any{"bar": "some-value"}},
173+
err: "failed to evaluate CEL expression as boolean: 'foo.bar'",
174+
},
175+
{
176+
name: "complex expression evaluating true",
177+
expr: "foo && bar",
178+
data: map[string]any{"foo": true, "bar": true},
179+
result: true,
180+
},
181+
{
182+
name: "complex expression evaluating false",
183+
expr: "foo && bar",
184+
data: map[string]any{"foo": true, "bar": false},
185+
result: false,
186+
},
187+
{
188+
name: "compiled expression returning true",
189+
expr: "foo.bar",
190+
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")},
191+
data: map[string]any{"foo": map[string]any{"bar": true}},
192+
result: true,
193+
},
194+
{
195+
name: "compiled expression returning false",
196+
expr: "foo.bar",
197+
opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")},
198+
data: map[string]any{"foo": map[string]any{"bar": false}},
199+
result: false,
200+
},
201+
} {
202+
t.Run(tt.name, func(t *testing.T) {
203+
t.Parallel()
204+
205+
g := NewWithT(t)
206+
207+
e, err := cel.NewExpression(tt.expr, tt.opts...)
208+
g.Expect(err).NotTo(HaveOccurred())
209+
210+
result, err := e.EvaluateBoolean(context.Background(), tt.data)
211+
212+
if tt.err != "" {
213+
g.Expect(err).To(HaveOccurred())
214+
g.Expect(err.Error()).To(ContainSubstring(tt.err))
215+
} else {
216+
g.Expect(err).NotTo(HaveOccurred())
217+
g.Expect(result).To(Equal(tt.result))
218+
}
219+
})
220+
}
221+
}

0 commit comments

Comments
 (0)