From 9f654e9ccea7e07c23a756d2108892129c390502 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Sat, 21 Feb 2026 08:51:38 +0100 Subject: [PATCH 1/6] test: new extra resources are merged into existing context Signed-off-by: Amund Tenstad --- fn_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/fn_test.go b/fn_test.go index 2dde8be..3ed0f64 100644 --- a/fn_test.go +++ b/fn_test.go @@ -230,6 +230,21 @@ func TestRunFunction(t *testing.T) { args: args{ req: &fnv1.RunFunctionRequest{ Meta: &fnv1.RequestMeta{Tag: "hello"}, + Context: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + v1beta1.FunctionContextKeyExtraResources: structpb.NewStructValue(resource.MustStructJSON(`{ + "previous": [ + { + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "previous" + } + } + ] + }`)), + }, + }, Observed: &fnv1.State{ Composite: &fnv1.Resource{ Resource: resource.MustStructJSON(`{ @@ -461,6 +476,15 @@ func TestRunFunction(t *testing.T) { Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ v1beta1.FunctionContextKeyExtraResources: structpb.NewStructValue(resource.MustStructJSON(`{ + "previous": [ + { + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "previous" + } + } + ], "obj-0": [ { "apiVersion": "apiextensions.crossplane.io/v1beta1", From 2c2336a44bb49daac7fc2240e32a6b19989d33df Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Sat, 21 Feb 2026 08:52:51 +0100 Subject: [PATCH 2/6] fix: merge extra resources into existing context Signed-off-by: Amund Tenstad --- fn.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/fn.go b/fn.go index e0662f6..99b5472 100644 --- a/fn.go +++ b/fn.go @@ -2,6 +2,7 @@ package main import ( "context" + "maps" "reflect" "sort" @@ -80,12 +81,20 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - s, err := structpb.NewStruct(verifiedExtras) + out, err := structpb.NewStruct(verifiedExtras) if err != nil { response.Fatal(rsp, errors.Wrapf(err, "cannot create new Struct from extra resources output")) return rsp, nil } - response.SetContextKey(rsp, in.Spec.Context.GetKey(), structpb.NewStructValue(s)) + + if v, ok := request.GetContextKey(req, in.Spec.Context.GetKey()); ok { + if s := v.GetStructValue(); s != nil { + maps.Copy(s.Fields, out.Fields) + out = s + } + } + + response.SetContextKey(rsp, in.Spec.Context.GetKey(), structpb.NewStructValue(out)) return rsp, nil } From 2ae36847eb1c80ca7994d9970590609965a156cc Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Sat, 21 Feb 2026 09:07:21 +0100 Subject: [PATCH 3/6] refactor: use GetFields instead of Fields Signed-off-by: Amund Tenstad --- fn.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fn.go b/fn.go index 99b5472..e967b8d 100644 --- a/fn.go +++ b/fn.go @@ -89,7 +89,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) if v, ok := request.GetContextKey(req, in.Spec.Context.GetKey()); ok { if s := v.GetStructValue(); s != nil { - maps.Copy(s.Fields, out.Fields) + maps.Copy(s.GetFields(), out.GetFields()) out = s } } From b2b5b525a3a26dc4db847ba7ecd5307047f26b53 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Sat, 21 Feb 2026 09:26:03 +0100 Subject: [PATCH 4/6] fix: make it none-breaking with context.policy Signed-off-by: Amund Tenstad --- fn.go | 10 +- fn_test.go | 121 ++++++++++++++++-- input/v1beta1/resource_select.go | 24 ++++ input/v1beta1/zz_generated.deepcopy.go | 5 + ...tra-resources.fn.crossplane.io_inputs.yaml | 8 ++ 5 files changed, 154 insertions(+), 14 deletions(-) diff --git a/fn.go b/fn.go index e967b8d..c0f7544 100644 --- a/fn.go +++ b/fn.go @@ -87,10 +87,12 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - if v, ok := request.GetContextKey(req, in.Spec.Context.GetKey()); ok { - if s := v.GetStructValue(); s != nil { - maps.Copy(s.GetFields(), out.GetFields()) - out = s + if in.Spec.Context.GetPolicy() == v1beta1.ContextPolicyMerge { + if v, ok := request.GetContextKey(req, in.Spec.Context.GetKey()); ok { + if s := v.GetStructValue(); s != nil { + maps.Copy(s.GetFields(), out.GetFields()) + out = s + } } } diff --git a/fn_test.go b/fn_test.go index 3ed0f64..d2755d6 100644 --- a/fn_test.go +++ b/fn_test.go @@ -476,15 +476,6 @@ func TestRunFunction(t *testing.T) { Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ v1beta1.FunctionContextKeyExtraResources: structpb.NewStructValue(resource.MustStructJSON(`{ - "previous": [ - { - "apiVersion": "apiextensions.crossplane.io/v1beta1", - "kind": "EnvironmentConfig", - "metadata": { - "name": "previous" - } - } - ], "obj-0": [ { "apiVersion": "apiextensions.crossplane.io/v1beta1", @@ -670,7 +661,7 @@ func TestRunFunction(t *testing.T) { "apiVersion": "apiextensions.crossplane.io/v1beta1", "type": "Reference", "into": "obj-0", - "ref": { + "ref": { "name": "my-env-config" } } @@ -712,6 +703,116 @@ func TestRunFunction(t *testing.T) { }, }, }, + "MergeContext": { + reason: "The Function should put resolved extra resources into existing context value when policy is Merge.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Context: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + v1beta1.FunctionContextKeyExtraResources: structpb.NewStructValue(resource.MustStructJSON(`{ + "previous": [ + { + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "previous" + } + } + ] + }`)), + }, + }, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr" + } + }`), + }, + }, + RequiredResources: map[string]*fnv1.Resources{ + "obj-0": { + Items: []*fnv1.Resource{ + { + Resource: resource.MustStructJSON(`{ + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "my-env-config" + } + }`), + }, + }, + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "extra-resources.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "context": { + "policy": "Merge" + }, + "extraResources": [ + { + "kind": "EnvironmentConfig", + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "type": "Reference", + "into": "obj-0", + "ref": { + "name": "my-env-config" + } + } + ] + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ + Resources: map[string]*fnv1.ResourceSelector{ + "obj-0": { + ApiVersion: "apiextensions.crossplane.io/v1beta1", + Kind: "EnvironmentConfig", + Match: &fnv1.ResourceSelector_MatchName{ + MatchName: "my-env-config", + }, + }, + }, + }, + Context: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + v1beta1.FunctionContextKeyExtraResources: structpb.NewStructValue(resource.MustStructJSON(`{ + "previous": [ + { + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "previous" + } + } + ], + "obj-0": [ + { + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "my-env-config" + } + } + ] + }`)), + }, + }, + }, + }, + }, } for name, tc := range cases { diff --git a/input/v1beta1/resource_select.go b/input/v1beta1/resource_select.go index 49dc69e..f64c45a 100644 --- a/input/v1beta1/resource_select.go +++ b/input/v1beta1/resource_select.go @@ -48,6 +48,11 @@ type Context struct { // standard functions such as Function Patch and Transform. // +kubebuilder:default=apiextensions.crossplane.io/extra-resources Key *string `json:"key,omitempty"` + + // Policy specifies how to handle the context's potentially existing value. + // +kubebuilder:default=Replace + // +kubebuilder:validation:Enum=Replace;Merge + Policy *ContextPolicy `json:"policy,omitempty"` } // GetKey returns the key of the context, defaulting to @@ -59,6 +64,25 @@ func (i *Context) GetKey() string { return *i.Key } +// ContextPolicy specifies how to handle the context's potentially existing value. +type ContextPolicy string + +const ( + // ContextPolicyReplace replaces the existing context key with the new results. + ContextPolicyReplace ContextPolicy = "Replace" + // ContextPolicyMerge merges keys at the top level of the context keys. + ContextPolicyMerge ContextPolicy = "Merge" +) + +// GetPolicy returns the policy of the context, defaulting to Replace if not +// specified. +func (i *Context) GetPolicy() ContextPolicy { + if i == nil || i.Policy == nil { + return ContextPolicyReplace + } + return *i.Policy +} + // Policy represents the Resolution policy of Reference instance. type Policy struct { // Resolution specifies whether resolution of this reference is required. diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index d75d0a0..104714d 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -17,6 +17,11 @@ func (in *Context) DeepCopyInto(out *Context) { *out = new(string) **out = **in } + if in.Policy != nil { + in, out := &in.Policy, &out.Policy + *out = new(ContextPolicy) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Context. diff --git a/package/input/extra-resources.fn.crossplane.io_inputs.yaml b/package/input/extra-resources.fn.crossplane.io_inputs.yaml index c5fa61f..ef644cb 100644 --- a/package/input/extra-resources.fn.crossplane.io_inputs.yaml +++ b/package/input/extra-resources.fn.crossplane.io_inputs.yaml @@ -52,6 +52,14 @@ spec: E.g. 'apiextensions.crossplane.io/environment', the environment used in standard functions such as Function Patch and Transform. type: string + policy: + default: Replace + description: Policy specifies how to handle the context's potentially + existing value. + enum: + - Replace + - Merge + type: string type: object extraResources: description: |- From 294ad26e3dd99d7687665b677a62a3f20a63359b Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Sat, 21 Feb 2026 12:47:37 +0100 Subject: [PATCH 5/6] refactor: avoid triple nested if Signed-off-by: Amund Tenstad --- fn.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/fn.go b/fn.go index c0f7544..c0cdcdd 100644 --- a/fn.go +++ b/fn.go @@ -81,22 +81,21 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - out, err := structpb.NewStruct(verifiedExtras) + s, err := structpb.NewStruct(verifiedExtras) if err != nil { response.Fatal(rsp, errors.Wrapf(err, "cannot create new Struct from extra resources output")) return rsp, nil } if in.Spec.Context.GetPolicy() == v1beta1.ContextPolicyMerge { - if v, ok := request.GetContextKey(req, in.Spec.Context.GetKey()); ok { - if s := v.GetStructValue(); s != nil { - maps.Copy(s.GetFields(), out.GetFields()) - out = s - } + v, _ := request.GetContextKey(req, in.Spec.Context.GetKey()) + if fields := v.GetStructValue().GetFields(); fields != nil { + maps.Copy(fields, s.GetFields()) + s.Fields = fields } } - response.SetContextKey(rsp, in.Spec.Context.GetKey(), structpb.NewStructValue(out)) + response.SetContextKey(rsp, in.Spec.Context.GetKey(), structpb.NewStructValue(s)) return rsp, nil } From 14dafa84bf588e084631ed3080904681dc59d284 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Sat, 21 Feb 2026 13:03:02 +0100 Subject: [PATCH 6/6] chore: move detailed policy descriptions so that they are included in CRD Signed-off-by: Amund Tenstad --- input/v1beta1/resource_select.go | 6 ++++-- package/input/extra-resources.fn.crossplane.io_inputs.yaml | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/input/v1beta1/resource_select.go b/input/v1beta1/resource_select.go index f64c45a..d5e44bb 100644 --- a/input/v1beta1/resource_select.go +++ b/input/v1beta1/resource_select.go @@ -50,6 +50,8 @@ type Context struct { Key *string `json:"key,omitempty"` // Policy specifies how to handle the context's potentially existing value. + // Replace replaces the existing context key with the new extra resources. + // Merge merges the extra resources into the context key's existing value. // +kubebuilder:default=Replace // +kubebuilder:validation:Enum=Replace;Merge Policy *ContextPolicy `json:"policy,omitempty"` @@ -68,9 +70,9 @@ func (i *Context) GetKey() string { type ContextPolicy string const ( - // ContextPolicyReplace replaces the existing context key with the new results. + // ContextPolicyReplace replaces the context key. ContextPolicyReplace ContextPolicy = "Replace" - // ContextPolicyMerge merges keys at the top level of the context keys. + // ContextPolicyMerge merges into the context key. ContextPolicyMerge ContextPolicy = "Merge" ) diff --git a/package/input/extra-resources.fn.crossplane.io_inputs.yaml b/package/input/extra-resources.fn.crossplane.io_inputs.yaml index ef644cb..168e9d8 100644 --- a/package/input/extra-resources.fn.crossplane.io_inputs.yaml +++ b/package/input/extra-resources.fn.crossplane.io_inputs.yaml @@ -54,8 +54,10 @@ spec: type: string policy: default: Replace - description: Policy specifies how to handle the context's potentially - existing value. + description: |- + Policy specifies how to handle the context's potentially existing value. + Replace replaces the existing context key with the new extra resources. + Merge merges the extra resources into the context key's existing value. enum: - Replace - Merge