Skip to content

Commit de80ee2

Browse files
committed
add support for CEL-based mutation steps in PublishedResources
On-behalf-of: @SAP christoph.mewes@sap.com
1 parent 6192589 commit de80ee2

10 files changed

Lines changed: 366 additions & 1 deletion

File tree

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:

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/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+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
"testing"
21+
22+
"github.com/kcp-dev/api-syncagent/internal/test/diff"
23+
syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1"
24+
"github.com/kcp-dev/api-syncagent/test/utils"
25+
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
27+
)
28+
29+
func TestCEL(t *testing.T) {
30+
commonInputObject := utils.YAMLToUnstructured(t, `
31+
apiVersion: kcp.example.com/v1
32+
kind: CronTab
33+
metadata:
34+
namespace: default
35+
name: my-crontab
36+
spec:
37+
cronSpec: '* * *'
38+
image: ubuntu:latest
39+
`)
40+
41+
testcases := []struct {
42+
name string
43+
inputData *unstructured.Unstructured
44+
otherObj *unstructured.Unstructured
45+
mutation syncagentv1alpha1.ResourceCELMutation
46+
expected *unstructured.Unstructured
47+
}{
48+
{
49+
name: "replace value at path with a fixed value of the same type",
50+
inputData: commonInputObject,
51+
mutation: syncagentv1alpha1.ResourceCELMutation{
52+
Path: "spec.cronSpec",
53+
Expression: `"hei verden"`,
54+
},
55+
expected: utils.YAMLToUnstructured(t, `
56+
apiVersion: kcp.example.com/v1
57+
kind: CronTab
58+
metadata:
59+
namespace: default
60+
name: my-crontab
61+
spec:
62+
cronSpec: "hei verden"
63+
image: ubuntu:latest
64+
`),
65+
},
66+
{
67+
name: "replace value at path with a fixed value of other type",
68+
inputData: commonInputObject,
69+
mutation: syncagentv1alpha1.ResourceCELMutation{
70+
Path: "spec.cronSpec",
71+
Expression: `42`,
72+
},
73+
expected: utils.YAMLToUnstructured(t, `
74+
apiVersion: kcp.example.com/v1
75+
kind: CronTab
76+
metadata:
77+
namespace: default
78+
name: my-crontab
79+
spec:
80+
cronSpec: 42
81+
image: ubuntu:latest
82+
`),
83+
},
84+
{
85+
name: "access the current value at the path",
86+
inputData: commonInputObject,
87+
mutation: syncagentv1alpha1.ResourceCELMutation{
88+
Path: "spec.cronSpec",
89+
Expression: `value + "foo"`,
90+
},
91+
expected: utils.YAMLToUnstructured(t, `
92+
apiVersion: kcp.example.com/v1
93+
kind: CronTab
94+
metadata:
95+
namespace: default
96+
name: my-crontab
97+
spec:
98+
cronSpec: '* * *foo'
99+
image: ubuntu:latest
100+
`),
101+
},
102+
{
103+
name: "access value from the object from the other side",
104+
inputData: commonInputObject,
105+
otherObj: commonInputObject,
106+
mutation: syncagentv1alpha1.ResourceCELMutation{
107+
Path: "spec.cronSpec",
108+
Expression: `other.spec.image`,
109+
},
110+
expected: utils.YAMLToUnstructured(t, `
111+
apiVersion: kcp.example.com/v1
112+
kind: CronTab
113+
metadata:
114+
namespace: default
115+
name: my-crontab
116+
spec:
117+
cronSpec: ubuntu:latest
118+
image: ubuntu:latest
119+
`),
120+
},
121+
}
122+
123+
for _, testcase := range testcases {
124+
t.Run(testcase.name, func(t *testing.T) {
125+
transformer, err := NewCEL(&testcase.mutation)
126+
if err != nil {
127+
t.Fatalf("Failed to create transformer: %v", err)
128+
}
129+
130+
transformed, err := transformer.Apply(testcase.inputData, testcase.otherObj)
131+
if err != nil {
132+
t.Fatalf("Failed to transform: %v", err)
133+
}
134+
135+
if changes := diff.ObjectDiff(testcase.expected, transformed); changes != "" {
136+
t.Errorf("Did not get expected object:\n\n%s", changes)
137+
}
138+
})
139+
}
140+
}

sdk/apis/syncagent/v1alpha1/published_resource.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ type ResourceMutation struct {
169169
Delete *ResourceDeleteMutation `json:"delete,omitempty"`
170170
Regex *ResourceRegexMutation `json:"regex,omitempty"`
171171
Template *ResourceTemplateMutation `json:"template,omitempty"`
172+
CEL *ResourceCELMutation `json:"cel,omitempty"`
172173
}
173174

174175
type ResourceDeleteMutation struct {
@@ -188,6 +189,11 @@ type ResourceTemplateMutation struct {
188189
Template string `json:"template"`
189190
}
190191

192+
type ResourceCELMutation struct {
193+
Path string `json:"path"`
194+
Expression string `json:"expression"`
195+
}
196+
191197
type RelatedResourceOrigin string
192198

193199
const (

sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)