Skip to content

Commit d87dbfd

Browse files
committed
Initial version of CEL resource expressions.
CEL for Receiver notification filtering This introduces CEL for filtering CEL resources in a Receiver. Users can define a CEL expression that is applied as a filter for resources that are identified for notification. A CEL expression that returns false means that a resource will not be annotated. Signed-off-by: Kevin McDermott <[email protected]>
1 parent 2ebbf48 commit d87dbfd

9 files changed

+528
-51
lines changed

api/v1/receiver_types.go

+5
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ type ReceiverSpec struct {
6767
// +required
6868
Resources []CrossNamespaceObjectReference `json:"resources"`
6969

70+
// ResourceFilter is an expression that is applied to each Resource
71+
// referenced in the Resources. If the expression returns false then the
72+
// Resource is discarded and will not be notified.
73+
ResourceFilter string `json:"resourceFilter,omitempty"`
74+
7075
// SecretRef specifies the Secret containing the token used
7176
// to validate the payload authenticity.
7277
// +required

config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ spec:
6262
Secret references.
6363
pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$
6464
type: string
65+
resourceFilter:
66+
description: |-
67+
ResourceFilter is an expression that is applied to each Resource
68+
referenced in the Resources. If the expression returns false then the
69+
Resource is discarded and will not be notified.
70+
type: string
6571
resources:
6672
description: A list of resources to be notified about changes.
6773
items:

docs/api/v1/notification.md

+36
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ e.g. &lsquo;push&rsquo; for GitHub or &lsquo;Push Hook&rsquo; for GitLab.</p>
122122
</tr>
123123
<tr>
124124
<td>
125+
<code>resourceExpressions</code><br>
126+
<em>
127+
[]string
128+
</em>
129+
</td>
130+
<td>
131+
<em>(Optional)</em>
132+
<p>ResourceExpressions is a list of CEL expressions that will be parsed to
133+
determine resources to be notified about changes.
134+
The expressions must evaluate to CEL values that contain the keys &ldquo;name&rdquo;,
135+
&ldquo;kind&rdquo;, &ldquo;apiVersion&rdquo; and optionally &ldquo;namespace&rdquo;.
136+
These values will be parsed to CrossNamespaceObjectReferences.
137+
e.g. {&ldquo;name&rdquo;: &ldquo;test-resource-1&rdquo;, &ldquo;kind&rdquo;: &ldquo;Receiver&rdquo;, &ldquo;apiVersion&rdquo;:
138+
&ldquo;notification.toolkit.fluxcd.io/v1&rdquo;}.</p>
139+
</td>
140+
</tr>
141+
<tr>
142+
<td>
125143
<code>secretRef</code><br>
126144
<em>
127145
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
@@ -321,6 +339,24 @@ e.g. &lsquo;push&rsquo; for GitHub or &lsquo;Push Hook&rsquo; for GitLab.</p>
321339
</tr>
322340
<tr>
323341
<td>
342+
<code>resourceExpressions</code><br>
343+
<em>
344+
[]string
345+
</em>
346+
</td>
347+
<td>
348+
<em>(Optional)</em>
349+
<p>ResourceExpressions is a list of CEL expressions that will be parsed to
350+
determine resources to be notified about changes.
351+
The expressions must evaluate to CEL values that contain the keys &ldquo;name&rdquo;,
352+
&ldquo;kind&rdquo;, &ldquo;apiVersion&rdquo; and optionally &ldquo;namespace&rdquo;.
353+
These values will be parsed to CrossNamespaceObjectReferences.
354+
e.g. {&ldquo;name&rdquo;: &ldquo;test-resource-1&rdquo;, &ldquo;kind&rdquo;: &ldquo;Receiver&rdquo;, &ldquo;apiVersion&rdquo;:
355+
&ldquo;notification.toolkit.fluxcd.io/v1&rdquo;}.</p>
356+
</td>
357+
</tr>
358+
<tr>
359+
<td>
324360
<code>secretRef</code><br>
325361
<em>
326362
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">

go.mod

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/fluxcd/pkg/ssa v0.41.1
2626
github.com/getsentry/sentry-go v0.29.0
2727
github.com/go-logr/logr v1.4.2
28+
github.com/google/cel-go v0.20.1
2829
github.com/google/go-github/v64 v64.0.0
2930
github.com/hashicorp/go-retryablehttp v0.7.7
3031
github.com/ktrysmt/go-bitbucket v0.9.80
@@ -74,6 +75,7 @@ require (
7475
github.com/DataDog/zstd v1.5.2 // indirect
7576
github.com/MakeNowJust/heredoc v1.0.0 // indirect
7677
github.com/ProtonMail/go-crypto v1.0.0 // indirect
78+
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
7779
github.com/beorn7/perks v1.0.1 // indirect
7880
github.com/blang/semver/v4 v4.0.0 // indirect
7981
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -159,6 +161,7 @@ require (
159161
github.com/russross/blackfriday/v2 v2.1.0 // indirect
160162
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
161163
github.com/spf13/cobra v1.8.1 // indirect
164+
github.com/stoewer/go-strcase v1.2.0 // indirect
162165
github.com/x448/float16 v0.8.4 // indirect
163166
github.com/xlab/treeprint v1.2.0 // indirect
164167
go.opencensus.io v0.24.0 // indirect

go.sum

+8
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ github.com/PagerDuty/go-pagerduty v1.8.0 h1:MTFqTffIcAervB83U7Bx6HERzLbyaSPL/+ox
7373
github.com/PagerDuty/go-pagerduty v1.8.0/go.mod h1:nzIeAqyFSJAFkjWKvMzug0JtwDg+V+UoCWjFrfFH5mI=
7474
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
7575
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
76+
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
77+
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
7678
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
7779
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
7880
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -215,6 +217,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
215217
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
216218
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
217219
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
220+
github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84=
221+
github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg=
218222
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
219223
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
220224
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -381,12 +385,15 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
381385
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
382386
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
383387
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
388+
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
389+
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
384390
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
385391
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
386392
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
387393
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
388394
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
389395
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
396+
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
390397
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
391398
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
392399
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -591,6 +598,7 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP
591598
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
592599
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
593600
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
601+
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
594602
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
595603
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
596604
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

internal/server/cel.go

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package server
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"mime"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/google/cel-go/cel"
11+
"github.com/google/cel-go/checker/decls"
12+
"github.com/google/cel-go/common/types"
13+
"github.com/google/cel-go/common/types/ref"
14+
"github.com/google/cel-go/common/types/traits"
15+
celext "github.com/google/cel-go/ext"
16+
"sigs.k8s.io/controller-runtime/pkg/client"
17+
)
18+
19+
func newCELEvaluator(expr string, req *http.Request) (resourcePredicate, error) {
20+
env, err := makeCELEnv()
21+
if err != nil {
22+
return nil, err
23+
}
24+
parsed, issues := env.Parse(expr)
25+
if issues != nil && issues.Err() != nil {
26+
return nil, fmt.Errorf("failed to parse expression %v: %w", expr, issues.Err())
27+
}
28+
29+
checked, issues := env.Check(parsed)
30+
if issues != nil && issues.Err() != nil {
31+
return nil, fmt.Errorf("expression %v check failed: %w", expr, issues.Err())
32+
}
33+
34+
prg, err := env.Program(checked, cel.EvalOptions(cel.OptOptimize))
35+
if err != nil {
36+
return nil, fmt.Errorf("expression %v failed to create a Program: %w", expr, err)
37+
}
38+
39+
body := map[string]any{}
40+
// Only decodes the body for the expression if the body is JSON.
41+
// Technically you could generate several resources without any body.
42+
if isJSONContent(req) {
43+
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
44+
return nil, fmt.Errorf("failed to parse request body as JSON: %s", err)
45+
}
46+
}
47+
48+
return func(obj client.Object) (*bool, error) {
49+
data, err := clientObjectToMap(obj)
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
out, _, err := prg.Eval(map[string]any{
55+
"resource": data,
56+
"request": body,
57+
})
58+
if err != nil {
59+
return nil, fmt.Errorf("expression %v failed to evaluate: %w", expr, err)
60+
}
61+
62+
v, ok := out.(types.Bool)
63+
if !ok {
64+
return nil, fmt.Errorf("expression %q did not return a boolean value", expr)
65+
}
66+
67+
result := v.Value().(bool)
68+
69+
return &result, nil
70+
}, nil
71+
}
72+
73+
func makeCELEnv() (*cel.Env, error) {
74+
mapStrDyn := decls.NewMapType(decls.String, decls.Dyn)
75+
return cel.NewEnv(
76+
celext.Strings(),
77+
celext.Encoders(),
78+
notifications(),
79+
cel.Declarations(
80+
decls.NewVar("resource", mapStrDyn),
81+
decls.NewVar("request", mapStrDyn),
82+
))
83+
}
84+
85+
func isJSONContent(r *http.Request) bool {
86+
contentType := r.Header.Get("Content-type")
87+
for _, v := range strings.Split(contentType, ",") {
88+
t, _, err := mime.ParseMediaType(v)
89+
if err != nil {
90+
break
91+
}
92+
if t == "application/json" {
93+
return true
94+
}
95+
}
96+
97+
return false
98+
}
99+
100+
func notifications() cel.EnvOption {
101+
r, err := types.NewRegistry()
102+
if err != nil {
103+
panic(err) // TODO: Do something better?
104+
}
105+
106+
return cel.Lib(&notificationsLib{registry: r})
107+
}
108+
109+
type notificationsLib struct {
110+
registry *types.Registry
111+
}
112+
113+
// LibraryName implements the SingletonLibrary interface method.
114+
func (*notificationsLib) LibraryName() string {
115+
return "flux.notifications.lib"
116+
}
117+
118+
// CompileOptions implements the Library interface method.
119+
func (l *notificationsLib) CompileOptions() []cel.EnvOption {
120+
listStrDyn := cel.ListType(cel.DynType)
121+
opts := []cel.EnvOption{
122+
cel.Function("first",
123+
cel.MemberOverload("first_list", []*cel.Type{listStrDyn}, cel.DynType,
124+
cel.UnaryBinding(listFirst))),
125+
cel.Function("last",
126+
cel.MemberOverload("last_list", []*cel.Type{listStrDyn}, cel.DynType,
127+
cel.UnaryBinding(listLast))),
128+
}
129+
130+
return opts
131+
}
132+
133+
// ProgramOptions implements the Library interface method.
134+
func (*notificationsLib) ProgramOptions() []cel.ProgramOption {
135+
return []cel.ProgramOption{}
136+
}
137+
138+
func listLast(val ref.Val) ref.Val {
139+
l := val.(traits.Lister)
140+
sz := l.Size().Value().(int64)
141+
142+
if sz == 0 {
143+
return types.NullValue
144+
}
145+
146+
return l.Get(types.Int(sz - 1))
147+
}
148+
149+
func listFirst(val ref.Val) ref.Val {
150+
l := val.(traits.Lister)
151+
sz := l.Size().Value().(int64)
152+
153+
if sz == 0 {
154+
return types.NullValue
155+
}
156+
157+
return l.Get(types.Int(0))
158+
}
159+
160+
func clientObjectToMap(v client.Object) (map[string]any, error) {
161+
b, err := json.Marshal(v)
162+
if err != nil {
163+
return nil, fmt.Errorf("failed to marshal PartialObjectMetadata from resource for CEL expression: %w", err)
164+
}
165+
166+
var result map[string]any
167+
if err := json.Unmarshal(b, &result); err != nil {
168+
return nil, fmt.Errorf("failed to unmarshal PartialObjectMetadata from resource for CEL expression: %w", err)
169+
}
170+
171+
return result, nil
172+
}

0 commit comments

Comments
 (0)