Skip to content

Commit be22b2b

Browse files
Implement Receiver resource filtering with CEL
Signed-off-by: Kevin McDermott <[email protected]> Co-authored-by: Matheus Pimenta <[email protected]>
1 parent 9b83efa commit be22b2b

13 files changed

+1406
-325
lines changed

api/v1/receiver_types.go

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

70+
// ResourceFilter is a CEL expression expected to return a boolean that is
71+
// evaluated for each resource referenced in the Resources field when a
72+
// webhook is received. If the expression returns false then the controller
73+
// will not request a reconciliation for the resource.
74+
// When the expression is specified the controller will parse it and mark
75+
// the object as terminally failed if the expression is invalid or does not
76+
// return a boolean.
77+
ResourceFilter string `json:"resourceFilter,omitempty"`
78+
7079
// SecretRef specifies the Secret containing the token used
7180
// to validate the payload authenticity.
7281
// +required

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

+10
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ spec:
6262
Secret references.
6363
pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$
6464
type: string
65+
resourceFilter:
66+
description: |-
67+
ResourceFilter is a CEL expression expected to return a boolean that is
68+
evaluated for each resource referenced in the Resources field when a
69+
webhook is received. If the expression returns false then the controller
70+
will not request a reconciliation for the resource.
71+
When the expression is specified the controller will parse it and mark
72+
the object as terminally failed if the expression is invalid or does not
73+
return a boolean.
74+
type: string
6575
resources:
6676
description: A list of resources to be notified about changes.
6777
items:

docs/api/v1/notification.md

+34
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,23 @@ e.g. &lsquo;push&rsquo; for GitHub or &lsquo;Push Hook&rsquo; for GitLab.</p>
122122
</tr>
123123
<tr>
124124
<td>
125+
<code>resourceFilter</code><br>
126+
<em>
127+
string
128+
</em>
129+
</td>
130+
<td>
131+
<p>ResourceFilter is a CEL expression expected to return a boolean that is
132+
evaluated for each resource referenced in the Resources field when a
133+
webhook is received. If the expression returns false then the controller
134+
will not request a reconciliation for the resource.
135+
When the expression is specified the controller will parse it and mark
136+
the object as terminally failed if the expression is invalid or does not
137+
return a boolean.</p>
138+
</td>
139+
</tr>
140+
<tr>
141+
<td>
125142
<code>secretRef</code><br>
126143
<em>
127144
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
@@ -321,6 +338,23 @@ e.g. &lsquo;push&rsquo; for GitHub or &lsquo;Push Hook&rsquo; for GitLab.</p>
321338
</tr>
322339
<tr>
323340
<td>
341+
<code>resourceFilter</code><br>
342+
<em>
343+
string
344+
</em>
345+
</td>
346+
<td>
347+
<p>ResourceFilter is a CEL expression expected to return a boolean that is
348+
evaluated for each resource referenced in the Resources field when a
349+
webhook is received. If the expression returns false then the controller
350+
will not request a reconciliation for the resource.
351+
When the expression is specified the controller will parse it and mark
352+
the object as terminally failed if the expression is invalid or does not
353+
return a boolean.</p>
354+
</td>
355+
</tr>
356+
<tr>
357+
<td>
324358
<code>secretRef</code><br>
325359
<em>
326360
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">

docs/spec/v1/receivers.md

+69
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,75 @@ resources:
700700
**Note:** Cross-namespace references [can be disabled for security
701701
reasons](#disabling-cross-namespace-selectors).
702702

703+
#### Filtering reconciled objects with CEL
704+
705+
To filter the resources that are reconciled you can use [Common Expression Language (CEL)](https://cel.dev/).
706+
707+
For example, to trigger `ImageRepositories` on notifications from [Google Artifact Registry](https://cloud.google.com/artifact-registry/docs/configure-notifications#examples) you can define the following receiver:
708+
709+
```yaml
710+
apiVersion: notification.toolkit.fluxcd.io/v1
711+
kind: Receiver
712+
metadata:
713+
name: gar-receiver
714+
namespace: apps
715+
spec:
716+
type: gcr
717+
secretRef:
718+
name: flux-gar-token
719+
resources:
720+
- apiVersion: image.toolkit.fluxcd.io/v1beta2
721+
kind: ImageRepository
722+
name: "*"
723+
matchLabels:
724+
registry: gar
725+
```
726+
727+
This will trigger the reconciliation of all `ImageRepositories` with the label `registry: gar`.
728+
729+
But if you want to only notify `ImageRepository` resources that are referenced from the incoming hook you can use CEL to filter the resources.
730+
731+
```yaml
732+
apiVersion: notification.toolkit.fluxcd.io/v1
733+
kind: Receiver
734+
metadata:
735+
name: gar-receiver
736+
namespace: apps
737+
spec:
738+
type: gcr
739+
secretRef:
740+
name: flux-gar-token
741+
resources:
742+
- apiVersion: image.toolkit.fluxcd.io/v1beta2
743+
kind: ImageRepository
744+
name: "*"
745+
matchLabels:
746+
registry: gar
747+
resourceFilter: 'req.tag.contains(res.metadata.name)'
748+
```
749+
750+
If the body of the incoming hook looks like this:
751+
752+
```json
753+
{
754+
"action":"INSERT",
755+
"digest":"us-east1-docker.pkg.dev/my-project/my-repo/hello-world@sha256:6ec128e26cd5...",
756+
"tag":"us-east1-docker.pkg.dev/my-project/my-repo/hello-world:1.1"
757+
}
758+
```
759+
760+
This simple example would match `ImageRepositories` containing the name `hello-world`.
761+
762+
If you want to do more complex processing:
763+
764+
```yaml
765+
resourceFilter: has(res.metadata.annotations) && req.tag.split('/').last().value().split(":").first().value() == res.metadata.annotations['update-image']
766+
```
767+
768+
This would look for an annotation "update-image" on the resource, and match it to the `hello-world` part of the tag name.
769+
770+
**Note:** Currently the `resource` value in the CEL expression only provides the object metadata, this means you can access things like `res.metadata.labels`, `res.metadata.annotations` and `res.metadata.name`.
771+
703772
### Secret reference
704773

705774
`.spec.secretRef.name` is a required field to specify a name reference to a

go.mod

+6
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/fluxcd/pkg/ssa v0.44.0
2626
github.com/getsentry/sentry-go v0.31.1
2727
github.com/go-logr/logr v1.4.2
28+
github.com/google/cel-go v0.23.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.81
@@ -51,6 +52,7 @@ require (
5152
replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
5253

5354
require (
55+
cel.dev/expr v0.19.1 // indirect
5456
cloud.google.com/go v0.116.0 // indirect
5557
cloud.google.com/go/auth v0.12.1 // indirect
5658
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
@@ -75,6 +77,7 @@ require (
7577
github.com/DataDog/zstd v1.5.2 // indirect
7678
github.com/MakeNowJust/heredoc v1.0.0 // indirect
7779
github.com/ProtonMail/go-crypto v1.1.5 // indirect
80+
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
7881
github.com/beorn7/perks v1.0.1 // indirect
7982
github.com/blang/semver/v4 v4.0.0 // indirect
8083
github.com/bradleyfalzon/ghinstallation/v2 v2.13.0 // indirect
@@ -92,6 +95,7 @@ require (
9295
github.com/fatih/color v1.16.0 // indirect
9396
github.com/felixge/httpsnoop v1.0.4 // indirect
9497
github.com/fluxcd/pkg/apis/acl v0.6.0 // indirect
98+
github.com/fluxcd/pkg/apis/kustomize v1.9.0 // indirect
9599
github.com/fluxcd/pkg/auth v0.3.0 // indirect
96100
github.com/fsnotify/fsnotify v1.8.0 // indirect
97101
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
@@ -160,6 +164,7 @@ require (
160164
github.com/russross/blackfriday/v2 v2.1.0 // indirect
161165
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
162166
github.com/spf13/cobra v1.8.1 // indirect
167+
github.com/stoewer/go-strcase v1.3.0 // indirect
163168
github.com/x448/float16 v0.8.4 // indirect
164169
github.com/xlab/treeprint v1.2.0 // indirect
165170
go.opencensus.io v0.24.0 // indirect
@@ -172,6 +177,7 @@ require (
172177
go.uber.org/multierr v1.11.0 // indirect
173178
go.uber.org/zap v1.27.0 // indirect
174179
golang.org/x/crypto v0.32.0 // indirect
180+
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
175181
golang.org/x/mod v0.22.0 // indirect
176182
golang.org/x/net v0.34.0 // indirect
177183
golang.org/x/sync v0.10.0 // indirect

go.sum

+12
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=
2+
cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
13
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
24
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
35
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
@@ -79,6 +81,8 @@ github.com/PagerDuty/go-pagerduty v1.8.0 h1:MTFqTffIcAervB83U7Bx6HERzLbyaSPL/+ox
7981
github.com/PagerDuty/go-pagerduty v1.8.0/go.mod h1:nzIeAqyFSJAFkjWKvMzug0JtwDg+V+UoCWjFrfFH5mI=
8082
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
8183
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
84+
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
85+
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
8286
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
8387
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
8488
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -146,6 +150,8 @@ github.com/fluxcd/pkg/apis/acl v0.6.0 h1:rllf5uQLzTow81ZCslkQ6LPpDNqVQr6/fWaNksd
146150
github.com/fluxcd/pkg/apis/acl v0.6.0/go.mod h1:IVDZx3MAoDWjlLrJHMF9Z27huFuXAEQlnbWw0M6EcTs=
147151
github.com/fluxcd/pkg/apis/event v0.16.0 h1:ffKc/3erowPnh72lFszz7sPQhLZ7bhqNrq+pu1Pb+JE=
148152
github.com/fluxcd/pkg/apis/event v0.16.0/go.mod h1:D/QQi5lHT9/Ur3OMFLJO71D4KDQHbJ5s8dQV3h1ZAT0=
153+
github.com/fluxcd/pkg/apis/kustomize v1.9.0 h1:SJpT1CK58AnTvCpDKeGfMNA0Xud/4VReZNvPe8XkTxo=
154+
github.com/fluxcd/pkg/apis/kustomize v1.9.0/go.mod h1:AZl2GU03oPVue6SUivdiIYd/3mvF94j7t1G2JO26d4s=
149155
github.com/fluxcd/pkg/apis/meta v1.10.0 h1:rqbAuyl5ug7A5jjRf/rNwBXmNl6tJ9wG2iIsriwnQUk=
150156
github.com/fluxcd/pkg/apis/meta v1.10.0/go.mod h1:n7NstXHDaleAUMajcXTVkhz0MYkvEXy1C/eLI/t1xoI=
151157
github.com/fluxcd/pkg/auth v0.3.0 h1:I1A3e81O+bpAgEcJ3e+rXqObKPjzBu6FLYXQTSxXLOs=
@@ -226,6 +232,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
226232
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
227233
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
228234
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
235+
github.com/google/cel-go v0.23.1 h1:91ThhEZlBcE5rB2adBVXqvDoqdL8BG2oyhd0bK1I/r4=
236+
github.com/google/cel-go v0.23.1/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo=
229237
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
230238
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
231239
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -396,6 +404,8 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k
396404
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
397405
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
398406
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
407+
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
408+
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
399409
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
400410
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
401411
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -459,6 +469,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
459469
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
460470
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
461471
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
472+
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk=
473+
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
462474
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
463475
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
464476
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=

internal/controller/receiver_controller.go

+17-2
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,29 @@ func (r *ReceiverReconciler) Reconcile(ctx context.Context, req ctrl.Request) (r
156156
// reconcile steps through the actual reconciliation tasks for the object, it returns early on the first step that
157157
// produces an error.
158158
func (r *ReceiverReconciler) reconcile(ctx context.Context, obj *apiv1.Receiver) (ctrl.Result, error) {
159+
log := ctrl.LoggerFrom(ctx)
160+
161+
if filter := obj.Spec.ResourceFilter; filter != "" {
162+
if err := server.ValidateResourceFilter(filter); err != nil {
163+
const msg = "Reconciliation failed terminally due to configuration error"
164+
errMsg := fmt.Sprintf("%s: %v", msg, err)
165+
conditions.MarkFalse(obj, meta.ReadyCondition, meta.InvalidCELExpressionReason, "%s", errMsg)
166+
conditions.MarkStalled(obj, meta.InvalidCELExpressionReason, "%s", errMsg)
167+
obj.Status.ObservedGeneration = obj.Generation
168+
log.Error(err, msg)
169+
r.Event(obj, corev1.EventTypeWarning, meta.InvalidCELExpressionReason, errMsg)
170+
return ctrl.Result{}, nil
171+
}
172+
}
173+
159174
// Mark the resource as under reconciliation.
160175
conditions.MarkReconciling(obj, meta.ProgressingReason, "Reconciliation in progress")
161176

162177
token, err := r.token(ctx, obj)
163178
if err != nil {
164179
conditions.MarkFalse(obj, meta.ReadyCondition, apiv1.TokenNotFoundReason, "%s", err)
165180
obj.Status.WebhookPath = ""
166-
return ctrl.Result{Requeue: true}, err
181+
return ctrl.Result{}, err
167182
}
168183

169184
webhookPath := obj.GetWebhookPath(token)
@@ -174,7 +189,7 @@ func (r *ReceiverReconciler) reconcile(ctx context.Context, obj *apiv1.Receiver)
174189

175190
if obj.Status.WebhookPath != webhookPath {
176191
obj.Status.WebhookPath = webhookPath
177-
ctrl.LoggerFrom(ctx).Info(msg)
192+
log.Info(msg)
178193
}
179194

180195
return ctrl.Result{RequeueAfter: obj.GetInterval()}, nil

internal/controller/receiver_controller_test.go

+37
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,43 @@ func TestReceiverReconciler_Reconcile(t *testing.T) {
144144
g.Expect(resultR.Spec.Interval.Duration).To(BeIdenticalTo(10 * time.Minute))
145145
})
146146

147+
t.Run("fails with invalid CEL resource filter", func(t *testing.T) {
148+
g := NewWithT(t)
149+
g.Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR)).To(Succeed())
150+
151+
// Incomplete CEL expression
152+
patch := []byte(`{"spec":{"resourceFilter":"has(res.metadata.annotations"}}`)
153+
g.Expect(k8sClient.Patch(context.Background(), resultR, client.RawPatch(types.MergePatchType, patch))).To(Succeed())
154+
155+
g.Eventually(func() bool {
156+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR)
157+
return !conditions.IsReady(resultR)
158+
}, timeout, time.Second).Should(BeTrue())
159+
160+
g.Expect(conditions.GetReason(resultR, meta.ReadyCondition)).To(BeIdenticalTo(meta.InvalidCELExpressionReason))
161+
g.Expect(conditions.GetMessage(resultR, meta.ReadyCondition)).To(ContainSubstring("annotations"))
162+
163+
g.Expect(conditions.Has(resultR, meta.ReconcilingCondition)).To(BeTrue())
164+
g.Expect(conditions.GetReason(resultR, meta.ReconcilingCondition)).To(BeIdenticalTo(meta.ProgressingWithRetryReason))
165+
g.Expect(conditions.GetObservedGeneration(resultR, meta.ReconcilingCondition)).To(BeIdenticalTo(resultR.Generation))
166+
})
167+
168+
t.Run("recovers when the CEL expression is valid", func(t *testing.T) {
169+
g := NewWithT(t)
170+
// Incomplete CEL expression
171+
patch := []byte(`{"spec":{"resourceFilter":"has(res.metadata.annotations)"}}`)
172+
g.Expect(k8sClient.Patch(context.Background(), resultR, client.RawPatch(types.MergePatchType, patch))).To(Succeed())
173+
174+
g.Eventually(func() bool {
175+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(receiver), resultR)
176+
return conditions.IsReady(resultR)
177+
}, timeout, time.Second).Should(BeTrue())
178+
179+
g.Expect(conditions.GetObservedGeneration(resultR, meta.ReadyCondition)).To(BeIdenticalTo(resultR.Generation))
180+
g.Expect(resultR.Status.ObservedGeneration).To(BeIdenticalTo(resultR.Generation))
181+
g.Expect(conditions.Has(resultR, meta.ReconcilingCondition)).To(BeFalse())
182+
})
183+
147184
t.Run("fails with secret not found error", func(t *testing.T) {
148185
g := NewWithT(t)
149186

0 commit comments

Comments
 (0)