diff --git a/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml b/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml index cd88b07..da26f48 100644 --- a/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml +++ b/deploy/crd/kcp.io/syncagent.kcp.io_publishedresources.yaml @@ -157,6 +157,16 @@ spec: spec: items: properties: + cel: + properties: + expression: + type: string + path: + type: string + required: + - expression + - path + type: object delete: properties: path: @@ -193,6 +203,16 @@ spec: status: items: properties: + cel: + properties: + expression: + type: string + path: + type: string + required: + - expression + - path + type: object delete: properties: path: @@ -396,6 +416,16 @@ spec: spec: items: properties: + cel: + properties: + expression: + type: string + path: + type: string + required: + - expression + - path + type: object delete: properties: path: @@ -432,6 +462,16 @@ spec: status: items: properties: + cel: + properties: + expression: + type: string + path: + type: string + required: + - expression + - path + type: object delete: properties: path: diff --git a/docs/content/publish-resources/index.md b/docs/content/publish-resources/index.md index 9d2ca15..cb730ac 100644 --- a/docs/content/publish-resources/index.md +++ b/docs/content/publish-resources/index.md @@ -236,6 +236,7 @@ spec: - regex: ... template: ... delete: ... + cel: ... ``` #### Regex @@ -273,6 +274,25 @@ delete: This mutation simply removes the value at the given path from the document. JSON path is the usual path, without a leading dot. +#### CEL Expressions + +```yaml +cel: + path: "metadata.resourceVersion" + expression: "value + 42" +``` + +This mutation applies a [CEL expression](https://cel.dev/) to a selected value (via `path`) in the +source object. For this mutation the syncagent will first get the current value at the `path` from +the Kubernetes object, then applies the CEl expression to it and updates the document with the +resulting value. + +Inside the CEL expression, the following variables are available: + +* `value` is the value selected by the `path` +* `self` is the object to modify +* `other` is the copy of this object on the other side of the sync + ### Related Resources The processing of resources on the service cluster often leads to additional resources being diff --git a/go.mod b/go.mod index 5a087d2..847ef22 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 + github.com/google/cel-go v0.23.2 github.com/google/go-cmp v0.7.0 github.com/kcp-dev/api-syncagent/sdk v0.0.0-00010101000000-000000000000 github.com/kcp-dev/kcp v0.28.1 @@ -81,7 +82,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.23.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect diff --git a/internal/controller/syncmanager/controller.go b/internal/controller/syncmanager/controller.go index cebe24f..8f5f966 100644 --- a/internal/controller/syncmanager/controller.go +++ b/internal/controller/syncmanager/controller.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "slices" "sync" "go.uber.org/zap" @@ -233,15 +234,23 @@ func (r *Reconciler) reconcile(ctx context.Context, log *zap.SugaredLogger, vwUR } // find all PublishedResources - pubResources := &syncagentv1alpha1.PublishedResourceList{} - if err := r.localManager.GetClient().List(ctx, pubResources, &ctrlruntimeclient.ListOptions{ + pubResList := &syncagentv1alpha1.PublishedResourceList{} + if err := r.localManager.GetClient().List(ctx, pubResList, &ctrlruntimeclient.ListOptions{ LabelSelector: r.prFilter, }); err != nil { return fmt.Errorf("failed to list PublishedResources: %w", err) } + // Filter out those that have not been processed into APIResourceSchemas yet; starting + // sync controllers too early, before the schemes are available, will make the watches + // not work properly. + // Also remove those PRs that have sync disabled. + pubResources := slices.DeleteFunc(pubResList.Items, func(pr syncagentv1alpha1.PublishedResource) bool { + return pr.Status.ResourceSchemaName == "" || !isSyncEnabled(&pr) + }) + // make sure that for every PublishedResource, a matching sync controller exists - if err := r.ensureSyncControllers(ctx, log, pubResources.Items); err != nil { + if err := r.ensureSyncControllers(ctx, log, pubResources); err != nil { return fmt.Errorf("failed to ensure sync controllers: %w", err) } @@ -415,9 +424,7 @@ func isSyncEnabled(pr *syncagentv1alpha1.PublishedResource) bool { func (r *Reconciler) ensureSyncControllers(ctx context.Context, log *zap.SugaredLogger, publishedResources []syncagentv1alpha1.PublishedResource) error { requiredWorkers := sets.New[string]() for _, pr := range publishedResources { - if isSyncEnabled(&pr) { - requiredWorkers.Insert(getPublishedResourceKey(&pr)) - } + requiredWorkers.Insert(getPublishedResourceKey(&pr)) } // stop controllers that are no longer needed @@ -437,10 +444,6 @@ func (r *Reconciler) ensureSyncControllers(ctx context.Context, log *zap.Sugared // start missing controllers for _, pubRes := range publishedResources { - if !isSyncEnabled(&pubRes) { - continue - } - key := getPublishedResourceKey(&pubRes) // controller already exists diff --git a/internal/mutation/mutator.go b/internal/mutation/mutator.go index 9744fa0..b843d8a 100644 --- a/internal/mutation/mutator.go +++ b/internal/mutation/mutator.go @@ -111,6 +111,12 @@ func createAggregatedTransformer(mutations []syncagentv1alpha1.ResourceMutation) return nil, err } + case mut.CEL != nil: + trans, err = transformer.NewCEL(mut.CEL) + if err != nil { + return nil, err + } + default: return nil, errors.New("no valid mutation mechanism provided") } diff --git a/internal/mutation/transformer/cel.go b/internal/mutation/transformer/cel.go new file mode 100644 index 0000000..b167aa6 --- /dev/null +++ b/internal/mutation/transformer/cel.go @@ -0,0 +1,94 @@ +/* +Copyright 2025 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package transformer + +import ( + "fmt" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/checker/decls" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type celTransformer struct { + path string + prg cel.Program +} + +func NewCEL(mut *syncagentv1alpha1.ResourceCELMutation) (*celTransformer, error) { + env, err := cel.NewEnv(cel.Declarations( + decls.NewVar("self", decls.Dyn), + decls.NewVar("other", decls.Dyn), + decls.NewVar("value", decls.Dyn), + )) + if err != nil { + return nil, fmt.Errorf("failed to create CEL env: %w", err) + } + + expr, issues := env.Compile(mut.Expression) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("failed to compile CEL expression: %w", issues.Err()) + } + + prg, err := env.Program(expr) + if err != nil { + return nil, fmt.Errorf("failed to create CEL program: %w", err) + } + + return &celTransformer{ + path: mut.Path, + prg: prg, + }, nil +} + +func (m *celTransformer) Apply(toMutate *unstructured.Unstructured, otherObj *unstructured.Unstructured) (*unstructured.Unstructured, error) { + encoded, err := EncodeObject(toMutate) + if err != nil { + return nil, fmt.Errorf("failed to JSON encode object: %w", err) + } + + // get the current value at the path + current := gjson.Get(encoded, m.path) + + input := map[string]any{ + "value": current.Value(), + "self": toMutate.Object, + "other": nil, + } + if otherObj != nil { + input["other"] = otherObj.Object + } + + // evaluate the expression + out, _, err := m.prg.Eval(input) + if err != nil { + return nil, fmt.Errorf("failed to evaluate CEL expression: %w", err) + } + + // update the object + updated, err := sjson.Set(encoded, m.path, out) + if err != nil { + return nil, fmt.Errorf("failed to set updated value: %w", err) + } + + return DecodeObject(updated) +} diff --git a/internal/mutation/transformer/cel_test.go b/internal/mutation/transformer/cel_test.go new file mode 100644 index 0000000..bf4ce68 --- /dev/null +++ b/internal/mutation/transformer/cel_test.go @@ -0,0 +1,140 @@ +/* +Copyright 2025 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package transformer + +import ( + "testing" + + "github.com/kcp-dev/api-syncagent/internal/test/diff" + syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + "github.com/kcp-dev/api-syncagent/test/utils" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestCEL(t *testing.T) { + commonInputObject := utils.YAMLToUnstructured(t, ` +apiVersion: kcp.example.com/v1 +kind: CronTab +metadata: + namespace: default + name: my-crontab +spec: + cronSpec: '* * *' + image: ubuntu:latest +`) + + testcases := []struct { + name string + inputData *unstructured.Unstructured + otherObj *unstructured.Unstructured + mutation syncagentv1alpha1.ResourceCELMutation + expected *unstructured.Unstructured + }{ + { + name: "replace value at path with a fixed value of the same type", + inputData: commonInputObject, + mutation: syncagentv1alpha1.ResourceCELMutation{ + Path: "spec.cronSpec", + Expression: `"hei verden"`, + }, + expected: utils.YAMLToUnstructured(t, ` +apiVersion: kcp.example.com/v1 +kind: CronTab +metadata: + namespace: default + name: my-crontab +spec: + cronSpec: "hei verden" + image: ubuntu:latest +`), + }, + { + name: "replace value at path with a fixed value of other type", + inputData: commonInputObject, + mutation: syncagentv1alpha1.ResourceCELMutation{ + Path: "spec.cronSpec", + Expression: `42`, + }, + expected: utils.YAMLToUnstructured(t, ` +apiVersion: kcp.example.com/v1 +kind: CronTab +metadata: + namespace: default + name: my-crontab +spec: + cronSpec: 42 + image: ubuntu:latest +`), + }, + { + name: "access the current value at the path", + inputData: commonInputObject, + mutation: syncagentv1alpha1.ResourceCELMutation{ + Path: "spec.cronSpec", + Expression: `value + "foo"`, + }, + expected: utils.YAMLToUnstructured(t, ` +apiVersion: kcp.example.com/v1 +kind: CronTab +metadata: + namespace: default + name: my-crontab +spec: + cronSpec: '* * *foo' + image: ubuntu:latest +`), + }, + { + name: "access value from the object from the other side", + inputData: commonInputObject, + otherObj: commonInputObject, + mutation: syncagentv1alpha1.ResourceCELMutation{ + Path: "spec.cronSpec", + Expression: `other.spec.image`, + }, + expected: utils.YAMLToUnstructured(t, ` +apiVersion: kcp.example.com/v1 +kind: CronTab +metadata: + namespace: default + name: my-crontab +spec: + cronSpec: ubuntu:latest + image: ubuntu:latest +`), + }, + } + + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + transformer, err := NewCEL(&testcase.mutation) + if err != nil { + t.Fatalf("Failed to create transformer: %v", err) + } + + transformed, err := transformer.Apply(testcase.inputData, testcase.otherObj) + if err != nil { + t.Fatalf("Failed to transform: %v", err) + } + + if changes := diff.ObjectDiff(testcase.expected, transformed); changes != "" { + t.Errorf("Did not get expected object:\n\n%s", changes) + } + }) + } +} diff --git a/sdk/apis/syncagent/v1alpha1/published_resource.go b/sdk/apis/syncagent/v1alpha1/published_resource.go index a750510..94c80ea 100644 --- a/sdk/apis/syncagent/v1alpha1/published_resource.go +++ b/sdk/apis/syncagent/v1alpha1/published_resource.go @@ -169,6 +169,7 @@ type ResourceMutation struct { Delete *ResourceDeleteMutation `json:"delete,omitempty"` Regex *ResourceRegexMutation `json:"regex,omitempty"` Template *ResourceTemplateMutation `json:"template,omitempty"` + CEL *ResourceCELMutation `json:"cel,omitempty"` } type ResourceDeleteMutation struct { @@ -188,6 +189,11 @@ type ResourceTemplateMutation struct { Template string `json:"template"` } +type ResourceCELMutation struct { + Path string `json:"path"` + Expression string `json:"expression"` +} + type RelatedResourceOrigin string const ( diff --git a/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go b/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go index e0ad61b..61724a3 100644 --- a/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go +++ b/sdk/apis/syncagent/v1alpha1/zz_generated.deepcopy.go @@ -316,6 +316,21 @@ func (in *RelatedResourceSpec) DeepCopy() *RelatedResourceSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceCELMutation) DeepCopyInto(out *ResourceCELMutation) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceCELMutation. +func (in *ResourceCELMutation) DeepCopy() *ResourceCELMutation { + if in == nil { + return nil + } + out := new(ResourceCELMutation) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceDeleteMutation) DeepCopyInto(out *ResourceDeleteMutation) { *out = *in @@ -374,6 +389,11 @@ func (in *ResourceMutation) DeepCopyInto(out *ResourceMutation) { *out = new(ResourceTemplateMutation) **out = **in } + if in.CEL != nil { + in, out := &in.CEL, &out.CEL + *out = new(ResourceCELMutation) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceMutation. diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/resourcecelmutation.go b/sdk/applyconfiguration/syncagent/v1alpha1/resourcecelmutation.go new file mode 100644 index 0000000..c1f7d61 --- /dev/null +++ b/sdk/applyconfiguration/syncagent/v1alpha1/resourcecelmutation.go @@ -0,0 +1,48 @@ +/* +Copyright The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by applyconfiguration-gen-v0.33. DO NOT EDIT. + +package v1alpha1 + +// ResourceCELMutationApplyConfiguration represents a declarative configuration of the ResourceCELMutation type for use +// with apply. +type ResourceCELMutationApplyConfiguration struct { + Path *string `json:"path,omitempty"` + Expression *string `json:"expression,omitempty"` +} + +// ResourceCELMutationApplyConfiguration constructs a declarative configuration of the ResourceCELMutation type for use with +// apply. +func ResourceCELMutation() *ResourceCELMutationApplyConfiguration { + return &ResourceCELMutationApplyConfiguration{} +} + +// WithPath sets the Path field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Path field is set to the value of the last call. +func (b *ResourceCELMutationApplyConfiguration) WithPath(value string) *ResourceCELMutationApplyConfiguration { + b.Path = &value + return b +} + +// WithExpression sets the Expression field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Expression field is set to the value of the last call. +func (b *ResourceCELMutationApplyConfiguration) WithExpression(value string) *ResourceCELMutationApplyConfiguration { + b.Expression = &value + return b +} diff --git a/sdk/applyconfiguration/syncagent/v1alpha1/resourcemutation.go b/sdk/applyconfiguration/syncagent/v1alpha1/resourcemutation.go index ed62337..7e1c678 100644 --- a/sdk/applyconfiguration/syncagent/v1alpha1/resourcemutation.go +++ b/sdk/applyconfiguration/syncagent/v1alpha1/resourcemutation.go @@ -24,6 +24,7 @@ type ResourceMutationApplyConfiguration struct { Delete *ResourceDeleteMutationApplyConfiguration `json:"delete,omitempty"` Regex *ResourceRegexMutationApplyConfiguration `json:"regex,omitempty"` Template *ResourceTemplateMutationApplyConfiguration `json:"template,omitempty"` + CEL *ResourceCELMutationApplyConfiguration `json:"cel,omitempty"` } // ResourceMutationApplyConfiguration constructs a declarative configuration of the ResourceMutation type for use with @@ -55,3 +56,11 @@ func (b *ResourceMutationApplyConfiguration) WithTemplate(value *ResourceTemplat b.Template = value return b } + +// WithCEL sets the CEL field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CEL field is set to the value of the last call. +func (b *ResourceMutationApplyConfiguration) WithCEL(value *ResourceCELMutationApplyConfiguration) *ResourceMutationApplyConfiguration { + b.CEL = value + return b +} diff --git a/sdk/applyconfiguration/utils.go b/sdk/applyconfiguration/utils.go index 0310667..57601ad 100644 --- a/sdk/applyconfiguration/utils.go +++ b/sdk/applyconfiguration/utils.go @@ -55,6 +55,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &syncagentv1alpha1.RelatedResourceSelectorRewriteApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("RelatedResourceSpec"): return &syncagentv1alpha1.RelatedResourceSpecApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("ResourceCELMutation"): + return &syncagentv1alpha1.ResourceCELMutationApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ResourceDeleteMutation"): return &syncagentv1alpha1.ResourceDeleteMutationApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ResourceFilter"): diff --git a/test/e2e/sync/related_test.go b/test/e2e/sync/related_test.go index 89f0655..03843a0 100644 --- a/test/e2e/sync/related_test.go +++ b/test/e2e/sync/related_test.go @@ -1524,9 +1524,6 @@ func TestSyncNonStandardRelatedResourcesMultipleAPIExports(t *testing.T) { t.Fatalf("Failed to create related object: %v", err) } - t.Log("sove...") - time.Sleep(30 * time.Second) - // wait for the agents to do their magic t.Log("Wait for related object to be synced…") projectedGVR := projection.RelatedResourceProjectedGVR(&testcase.relatedConfig)