Skip to content

Commit 2d86372

Browse files
authored
Merge pull request #125 from kcp-dev/cel-mutations
Add support for CEL-based mutation rules
2 parents 6192589 + 361114a commit 2d86372

File tree

13 files changed

+399
-14
lines changed

13 files changed

+399
-14
lines changed

deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ spec:
157157
spec:
158158
items:
159159
properties:
160+
cel:
161+
properties:
162+
expression:
163+
type: string
164+
path:
165+
type: string
166+
required:
167+
- expression
168+
- path
169+
type: object
160170
delete:
161171
properties:
162172
path:
@@ -193,6 +203,16 @@ spec:
193203
status:
194204
items:
195205
properties:
206+
cel:
207+
properties:
208+
expression:
209+
type: string
210+
path:
211+
type: string
212+
required:
213+
- expression
214+
- path
215+
type: object
196216
delete:
197217
properties:
198218
path:
@@ -396,6 +416,16 @@ spec:
396416
spec:
397417
items:
398418
properties:
419+
cel:
420+
properties:
421+
expression:
422+
type: string
423+
path:
424+
type: string
425+
required:
426+
- expression
427+
- path
428+
type: object
399429
delete:
400430
properties:
401431
path:
@@ -432,6 +462,16 @@ spec:
432462
status:
433463
items:
434464
properties:
465+
cel:
466+
properties:
467+
expression:
468+
type: string
469+
path:
470+
type: string
471+
required:
472+
- expression
473+
- path
474+
type: object
435475
delete:
436476
properties:
437477
path:

docs/content/publish-resources/index.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ spec:
236236
- regex: ...
237237
template: ...
238238
delete: ...
239+
cel: ...
239240
```
240241

241242
#### Regex
@@ -273,6 +274,25 @@ delete:
273274
This mutation simply removes the value at the given path from the document. JSON path is the
274275
usual path, without a leading dot.
275276

277+
#### CEL Expressions
278+
279+
```yaml
280+
cel:
281+
path: "metadata.resourceVersion"
282+
expression: "value + 42"
283+
```
284+
285+
This mutation applies a [CEL expression](https://cel.dev/) to a selected value (via `path`) in the
286+
source object. For this mutation the syncagent will first get the current value at the `path` from
287+
the Kubernetes object, then applies the CEl expression to it and updates the document with the
288+
resulting value.
289+
290+
Inside the CEL expression, the following variables are available:
291+
292+
* `value` is the value selected by the `path`
293+
* `self` is the object to modify
294+
* `other` is the copy of this object on the other side of the sync
295+
276296
### Related Resources
277297

278298
The processing of resources on the service cluster often leads to additional resources being

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/evanphx/json-patch/v5 v5.9.11
1515
github.com/go-logr/logr v1.4.3
1616
github.com/go-logr/zapr v1.3.0
17+
github.com/google/cel-go v0.23.2
1718
github.com/google/go-cmp v0.7.0
1819
github.com/kcp-dev/api-syncagent/sdk v0.0.0-00010101000000-000000000000
1920
github.com/kcp-dev/kcp v0.28.1
@@ -81,7 +82,6 @@ require (
8182
github.com/gogo/protobuf v1.3.2 // indirect
8283
github.com/golang/protobuf v1.5.4 // indirect
8384
github.com/google/btree v1.1.3 // indirect
84-
github.com/google/cel-go v0.23.2 // indirect
8585
github.com/google/gnostic-models v0.6.9 // indirect
8686
github.com/google/uuid v1.6.0 // indirect
8787
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect

internal/controller/syncmanager/controller.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"errors"
2222
"fmt"
23+
"slices"
2324
"sync"
2425

2526
"go.uber.org/zap"
@@ -233,15 +234,23 @@ func (r *Reconciler) reconcile(ctx context.Context, log *zap.SugaredLogger, vwUR
233234
}
234235

235236
// find all PublishedResources
236-
pubResources := &syncagentv1alpha1.PublishedResourceList{}
237-
if err := r.localManager.GetClient().List(ctx, pubResources, &ctrlruntimeclient.ListOptions{
237+
pubResList := &syncagentv1alpha1.PublishedResourceList{}
238+
if err := r.localManager.GetClient().List(ctx, pubResList, &ctrlruntimeclient.ListOptions{
238239
LabelSelector: r.prFilter,
239240
}); err != nil {
240241
return fmt.Errorf("failed to list PublishedResources: %w", err)
241242
}
242243

244+
// Filter out those that have not been processed into APIResourceSchemas yet; starting
245+
// sync controllers too early, before the schemes are available, will make the watches
246+
// not work properly.
247+
// Also remove those PRs that have sync disabled.
248+
pubResources := slices.DeleteFunc(pubResList.Items, func(pr syncagentv1alpha1.PublishedResource) bool {
249+
return pr.Status.ResourceSchemaName == "" || !isSyncEnabled(&pr)
250+
})
251+
243252
// make sure that for every PublishedResource, a matching sync controller exists
244-
if err := r.ensureSyncControllers(ctx, log, pubResources.Items); err != nil {
253+
if err := r.ensureSyncControllers(ctx, log, pubResources); err != nil {
245254
return fmt.Errorf("failed to ensure sync controllers: %w", err)
246255
}
247256

@@ -415,9 +424,7 @@ func isSyncEnabled(pr *syncagentv1alpha1.PublishedResource) bool {
415424
func (r *Reconciler) ensureSyncControllers(ctx context.Context, log *zap.SugaredLogger, publishedResources []syncagentv1alpha1.PublishedResource) error {
416425
requiredWorkers := sets.New[string]()
417426
for _, pr := range publishedResources {
418-
if isSyncEnabled(&pr) {
419-
requiredWorkers.Insert(getPublishedResourceKey(&pr))
420-
}
427+
requiredWorkers.Insert(getPublishedResourceKey(&pr))
421428
}
422429

423430
// stop controllers that are no longer needed
@@ -437,10 +444,6 @@ func (r *Reconciler) ensureSyncControllers(ctx context.Context, log *zap.Sugared
437444

438445
// start missing controllers
439446
for _, pubRes := range publishedResources {
440-
if !isSyncEnabled(&pubRes) {
441-
continue
442-
}
443-
444447
key := getPublishedResourceKey(&pubRes)
445448

446449
// controller already exists

internal/mutation/mutator.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ func createAggregatedTransformer(mutations []syncagentv1alpha1.ResourceMutation)
111111
return nil, err
112112
}
113113

114+
case mut.CEL != nil:
115+
trans, err = transformer.NewCEL(mut.CEL)
116+
if err != nil {
117+
return nil, err
118+
}
119+
114120
default:
115121
return nil, errors.New("no valid mutation mechanism provided")
116122
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright 2025 The KCP 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 transformer
18+
19+
import (
20+
"fmt"
21+
22+
"github.com/google/cel-go/cel"
23+
"github.com/google/cel-go/checker/decls"
24+
"github.com/tidwall/gjson"
25+
"github.com/tidwall/sjson"
26+
27+
syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1"
28+
29+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
30+
)
31+
32+
type celTransformer struct {
33+
path string
34+
prg cel.Program
35+
}
36+
37+
func NewCEL(mut *syncagentv1alpha1.ResourceCELMutation) (*celTransformer, error) {
38+
env, err := cel.NewEnv(cel.Declarations(
39+
decls.NewVar("self", decls.Dyn),
40+
decls.NewVar("other", decls.Dyn),
41+
decls.NewVar("value", decls.Dyn),
42+
))
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to create CEL env: %w", err)
45+
}
46+
47+
expr, issues := env.Compile(mut.Expression)
48+
if issues != nil && issues.Err() != nil {
49+
return nil, fmt.Errorf("failed to compile CEL expression: %w", issues.Err())
50+
}
51+
52+
prg, err := env.Program(expr)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to create CEL program: %w", err)
55+
}
56+
57+
return &celTransformer{
58+
path: mut.Path,
59+
prg: prg,
60+
}, nil
61+
}
62+
63+
func (m *celTransformer) Apply(toMutate *unstructured.Unstructured, otherObj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
64+
encoded, err := EncodeObject(toMutate)
65+
if err != nil {
66+
return nil, fmt.Errorf("failed to JSON encode object: %w", err)
67+
}
68+
69+
// get the current value at the path
70+
current := gjson.Get(encoded, m.path)
71+
72+
input := map[string]any{
73+
"value": current.Value(),
74+
"self": toMutate.Object,
75+
"other": nil,
76+
}
77+
if otherObj != nil {
78+
input["other"] = otherObj.Object
79+
}
80+
81+
// evaluate the expression
82+
out, _, err := m.prg.Eval(input)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to evaluate CEL expression: %w", err)
85+
}
86+
87+
// update the object
88+
updated, err := sjson.Set(encoded, m.path, out)
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to set updated value: %w", err)
91+
}
92+
93+
return DecodeObject(updated)
94+
}

0 commit comments

Comments
 (0)