From f0fc61927ae2ac912b6ec693b759984665b57aef Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Tue, 10 Feb 2026 17:46:26 +0100 Subject: [PATCH 01/14] refactor(from-fieldpath): extract append from type switch Signed-off-by: Amund Tenstad --- fn.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fn.go b/fn.go index f72ce1d..f3affae 100644 --- a/fn.go +++ b/fn.go @@ -179,6 +179,7 @@ func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource if !ok { return nil, errors.Errorf("cannot find expected extra resource %q", extraResName) } + switch extraResource.GetType() { case v1beta1.ResourceSourceTypeReference: if len(resources) == 0 { @@ -190,7 +191,6 @@ func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource if len(resources) > 1 { return nil, errors.Errorf("expected exactly one extra resource %q, got %d", extraResName, len(resources)) } - cleanedExtras[extraResName] = append(cleanedExtras[extraResName], *resources[0].Resource) case v1beta1.ResourceSourceTypeSelector: selector := extraResource.Selector @@ -203,9 +203,10 @@ func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource if selector.MaxMatch != nil && uint64(len(resources)) > *selector.MaxMatch { resources = resources[:*selector.MaxMatch] } - for _, r := range resources { - cleanedExtras[extraResName] = append(cleanedExtras[extraResName], *r.Resource) - } + } + + for _, r := range resources { + cleanedExtras[extraResName] = append(cleanedExtras[extraResName], *r.Resource) } } return cleanedExtras, nil From d22f8670c91a1659fb4da715d8b39ef3a8c1a163 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Tue, 10 Feb 2026 18:57:23 +0100 Subject: [PATCH 02/14] refactor(from-fieldpath): avoid json marshal -> unmarshal into structpb Signed-off-by: Amund Tenstad --- fn.go | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/fn.go b/fn.go index f3affae..5d11fb0 100644 --- a/fn.go +++ b/fn.go @@ -2,11 +2,9 @@ package main import ( "context" - "encoding/json" "reflect" "sort" - "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/structpb" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -88,27 +86,24 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - // For now cheaply convert to JSON for serializing. - // - // TODO(reedjosh): look into resources.AsStruct or simlar since unsturctured k8s objects are already almost json. - // structpb.NewList(v []interface{}) should create an array like. - // Combining this and similar structures from the structpb lib should should be done to create - // a map[string][object] container into which the found extra resources can be dumped. - // - // The found extra resources should then be directly marhsal-able via: - // obj := &unstructured.Unstructured{} - // obj.MarshalJSON() - b, err := json.Marshal(verifiedExtras) - if err != nil { - response.Fatal(rsp, errors.Errorf("cannot marshal %T: %w", verifiedExtras, err)) - return rsp, nil + out := &unstructured.Unstructured{Object: map[string]interface{}{}} + for into, extras := range verifiedExtras { + li := []interface{}{} + for _, e := range extras { + li = append(li, e.Object) + } + if err := fieldpath.Pave(out.Object).SetValue(into, li); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set nested field path %q", into)) + return rsp, nil + } } - s := &structpb.Struct{} - err = protojson.Unmarshal(b, s) + + s, err := resource.AsStruct(out) if err != nil { - response.Fatal(rsp, errors.Errorf("cannot unmarshal %T into %T: %w", extraResources, s, err)) + response.Fatal(rsp, errors.Wrap(err, "cannot convert unstructured to protobuf Struct well-known type")) return rsp, nil } + response.SetContextKey(rsp, FunctionContextKeyExtraResources, structpb.NewStructValue(s)) return rsp, nil From 498d4c7b813ce64b462bd8acb0d0ae39d242bb23 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 09:08:06 +0100 Subject: [PATCH 03/14] feat(from-fieldpath): FromFieldPath to extract only parts of resources Signed-off-by: Amund Tenstad --- fn.go | 38 +++++++++++++------ input/v1beta1/resource_select.go | 3 ++ input/v1beta1/zz_generated.deepcopy.go | 5 +++ ...tra-resources.fn.crossplane.io_inputs.yaml | 6 ++- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/fn.go b/fn.go index 5d11fb0..5ab0336 100644 --- a/fn.go +++ b/fn.go @@ -31,6 +31,11 @@ type Function struct { log logging.Logger } +type FetchedResult struct { + source v1beta1.ResourceSource + resources []interface{} +} + // RunFunction runs the Function. func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) { f.log.Info("Running function", "tag", req.GetMeta().GetTag()) @@ -87,13 +92,9 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) } out := &unstructured.Unstructured{Object: map[string]interface{}{}} - for into, extras := range verifiedExtras { - li := []interface{}{} - for _, e := range extras { - li = append(li, e.Object) - } - if err := fieldpath.Pave(out.Object).SetValue(into, li); err != nil { - response.Fatal(rsp, errors.Wrapf(err, "cannot set nested field path %q", into)) + for _, extras := range verifiedExtras { + if err := fieldpath.Pave(out.Object).SetValue(extras.source.Into, extras.resources); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set nested field path %q", extras.source.Into)) return rsp, nil } } @@ -166,8 +167,8 @@ func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Require // Verify Min/Max and sort extra resources by field path within a single kind. func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource.Required, //nolint:gocyclo // TODO(reedjosh): refactor -) (cleanedExtras map[string][]unstructured.Unstructured, err error) { - cleanedExtras = make(map[string][]unstructured.Unstructured) +) ([]FetchedResult, error) { + results := []FetchedResult{} for _, extraResource := range in.Spec.ExtraResources { extraResName := extraResource.Into resources, ok := extraResources[extraResName] @@ -200,11 +201,26 @@ func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource } } + result := FetchedResult{source: extraResource} for _, r := range resources { - cleanedExtras[extraResName] = append(cleanedExtras[extraResName], *r.Resource) + if path := extraResource.FromFieldPath; path != nil { + if *path == "" { + return nil, errors.New("fromFieldPath cannot be empty, omit the field to get the whole object") + } + + // Extract part of the object, from `FromFieldPath`. + object, err := fieldpath.Pave(r.Resource.Object).GetValue(*path) + if err != nil { + return nil, err + } + result.resources = append(result.resources, object) + } else { + result.resources = append(result.resources, r.Resource.Object) + } } + results = append(results, result) } - return cleanedExtras, nil + return results, nil } // Sort extra resources by field path within a single kind. diff --git a/input/v1beta1/resource_select.go b/input/v1beta1/resource_select.go index 224bba2..20e3c1e 100644 --- a/input/v1beta1/resource_select.go +++ b/input/v1beta1/resource_select.go @@ -96,6 +96,9 @@ type ResourceSource struct { // Into is the key into which extra resources for this selector will be placed. Into string `json:"into"` + + // FromFieldPath specifies a field path to extract from the object, instead of the whole object. + FromFieldPath *string `json:"fromFieldPath,omitempty"` } // GetType returns the type of the resource source, returning the default if not set. diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index 1a9813d..a5a7cdf 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -125,6 +125,11 @@ func (in *ResourceSource) DeepCopyInto(out *ResourceSource) { *out = new(string) **out = **in } + if in.FromFieldPath != nil { + in, out := &in.FromFieldPath, &out.FromFieldPath + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceSource. diff --git a/package/input/extra-resources.fn.crossplane.io_inputs.yaml b/package/input/extra-resources.fn.crossplane.io_inputs.yaml index 7fa15b0..30cef0d 100644 --- a/package/input/extra-resources.fn.crossplane.io_inputs.yaml +++ b/package/input/extra-resources.fn.crossplane.io_inputs.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.0 + controller-gen.kubebuilder.io/version: v0.18.0 name: inputs.extra-resources.fn.crossplane.io spec: group: extra-resources.fn.crossplane.io @@ -53,6 +53,10 @@ spec: description: APIVersion is the kubernetes API Version of the target extra resource(s). type: string + fromFieldPath: + description: FromFieldPath specifies a field path to extract + from the object, instead of the whole object. + type: string into: description: Into is the key into which extra resources for this selector will be placed. From 2e39643db28cba2643eb51c67b7f70740d6f7d60 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 09:14:00 +0100 Subject: [PATCH 04/14] test(from-fieldpath): fetch only metadata.name of resources using fromFieldPath Signed-off-by: Amund Tenstad --- fn_test.go | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/fn_test.go b/fn_test.go index 6f0b726..d390647 100644 --- a/fn_test.go +++ b/fn_test.go @@ -600,6 +600,110 @@ func TestRunFunction(t *testing.T) { }, }, }, + "FromField": { + reason: "The Function should extract from FromFieldPath.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + 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": "first", + "labels": { + "foo": "bar" + } + } + }`), + }, + { + Resource: resource.MustStructJSON(`{ + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "second", + "labels": { + "foo": "bar" + } + } + }`), + }, + }, + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "extra-resources.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "extraResources": [ + { + "into": "obj-0", + "kind": "EnvironmentConfig", + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "fromFieldPath": "metadata.name", + "type": "Selector", + "selector": { + "matchLabels": [ + { + "type": "Value", + "key": "foo", + "value": "bar" + } + ] + } + } + ] + } + }`), + }, + }, + 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_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{ + Labels: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + }, + }, + Context: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + FunctionContextKeyExtraResources: structpb.NewStructValue(resource.MustStructJSON(`{ + "obj-0": [ + "first", + "second" + ] + }`)), + }, + }, + }, + }, + }, } for name, tc := range cases { From f05ce5b4ebc57616212ca81f85d19d5c5d579a51 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 09:21:01 +0100 Subject: [PATCH 05/14] feat(to-fieldpath)!: rename into to toFieldPath and support subfields within context Signed-off-by: Amund Tenstad --- README.md | 2 +- example/composition.yaml | 4 +-- fn.go | 8 +++--- fn_test.go | 28 +++++++++---------- input/v1beta1/resource_select.go | 4 +-- ...tra-resources.fn.crossplane.io_inputs.yaml | 10 +++---- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 23a8315..dadeeb4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ spec: spec: extraResources: - kind: XCluster - into: XCluster + toFieldPath: XCluster apiVersion: example.crossplane.io/v1 type: Selector selector: diff --git a/example/composition.yaml b/example/composition.yaml index ee15a54..189f067 100644 --- a/example/composition.yaml +++ b/example/composition.yaml @@ -17,7 +17,7 @@ spec: spec: extraResources: - kind: EnvironmentConfig - into: envConfs + toFieldPath: envConfs apiVersion: apiextensions.crossplane.io/v1alpha1 type: Selector selector: @@ -28,7 +28,7 @@ spec: type: Value value: cluster - kind: XCluster - into: XCluster + toFieldPath: XCluster apiVersion: example.crossplane.io/v1 type: Selector selector: diff --git a/fn.go b/fn.go index 5ab0336..7240ab4 100644 --- a/fn.go +++ b/fn.go @@ -93,8 +93,8 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) out := &unstructured.Unstructured{Object: map[string]interface{}{}} for _, extras := range verifiedExtras { - if err := fieldpath.Pave(out.Object).SetValue(extras.source.Into, extras.resources); err != nil { - response.Fatal(rsp, errors.Wrapf(err, "cannot set nested field path %q", extras.source.Into)) + if err := fieldpath.Pave(out.Object).SetValue(extras.source.ToFieldPath, extras.resources); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set nested field path %q", extras.source.ToFieldPath)) return rsp, nil } } @@ -115,7 +115,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Requirements, error) { //nolint:gocyclo // Adding non-nil validations increases function complexity. extraResources := make(map[string]*fnv1.ResourceSelector, len(in.Spec.ExtraResources)) for _, extraResource := range in.Spec.ExtraResources { - extraResName := extraResource.Into + extraResName := extraResource.ToFieldPath switch extraResource.Type { case v1beta1.ResourceSourceTypeReference, "": extraResources[extraResName] = &fnv1.ResourceSelector{ @@ -170,7 +170,7 @@ func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource ) ([]FetchedResult, error) { results := []FetchedResult{} for _, extraResource := range in.Spec.ExtraResources { - extraResName := extraResource.Into + extraResName := extraResource.ToFieldPath resources, ok := extraResources[extraResName] if !ok { return nil, errors.Errorf("cannot find expected extra resource %q", extraResName) diff --git a/fn_test.go b/fn_test.go index d390647..4e97fb5 100644 --- a/fn_test.go +++ b/fn_test.go @@ -63,7 +63,7 @@ func TestRunFunction(t *testing.T) { "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "type": "Reference", - "into": "obj-0", + "toFieldPath": "obj-0", "ref": { "name": "my-env-config" } @@ -72,7 +72,7 @@ func TestRunFunction(t *testing.T) { "type": "Reference", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", - "into": "obj-1", + "toFieldPath": "obj-1", "ref": { "name": "my-second-env-config" } @@ -81,7 +81,7 @@ func TestRunFunction(t *testing.T) { "type": "Selector", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", - "into": "obj-2", + "toFieldPath": "obj-2", "selector": { "matchLabels": [ { @@ -96,7 +96,7 @@ func TestRunFunction(t *testing.T) { "type": "Selector", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", - "into": "obj-3", + "toFieldPath": "obj-3", "selector": { "matchLabels": [ { @@ -111,7 +111,7 @@ func TestRunFunction(t *testing.T) { "type": "Selector", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", - "into": "obj-4", + "toFieldPath": "obj-4", "selector": { "matchLabels": [ { @@ -127,7 +127,7 @@ func TestRunFunction(t *testing.T) { "kind": "Foo", "apiVersion": "test.crossplane.io/v1alpha1", "namespace": "my-namespace", - "into": "obj-5", + "toFieldPath": "obj-5", "ref": { "name": "my-foo" } @@ -137,7 +137,7 @@ func TestRunFunction(t *testing.T) { "kind": "Bar", "apiVersion": "test.crossplane.io/v1alpha1", "namespace": "my-namespace", - "into": "obj-6", + "toFieldPath": "obj-6", "selector": { "matchLabels": [ { @@ -345,7 +345,7 @@ func TestRunFunction(t *testing.T) { "extraResources": [ { "type": "Reference", - "into": "obj-0", + "toFieldPath": "obj-0", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "ref": { @@ -354,7 +354,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Reference", - "into": "obj-1", + "toFieldPath": "obj-1", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "ref": { @@ -363,7 +363,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "into": "obj-2", + "toFieldPath": "obj-2", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "selector": { @@ -378,7 +378,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "into": "obj-3", + "toFieldPath": "obj-3", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "selector": { @@ -393,7 +393,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "into": "obj-4", + "toFieldPath": "obj-4", "apiVersion": "apiextensions.crossplane.io/v1beta1", "kind": "EnvironmentConfig", "selector": { @@ -565,7 +565,7 @@ func TestRunFunction(t *testing.T) { "extraResources": [ { "type": "Reference", - "into": "obj-0", + "toFieldPath": "obj-0", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "ref": { @@ -652,7 +652,7 @@ func TestRunFunction(t *testing.T) { "spec": { "extraResources": [ { - "into": "obj-0", + "toFieldPath": "obj-0", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "fromFieldPath": "metadata.name", diff --git a/input/v1beta1/resource_select.go b/input/v1beta1/resource_select.go index 20e3c1e..f0df7af 100644 --- a/input/v1beta1/resource_select.go +++ b/input/v1beta1/resource_select.go @@ -94,8 +94,8 @@ type ResourceSource struct { // +optional Namespace *string `json:"namespace,omitempty"` - // Into is the key into which extra resources for this selector will be placed. - Into string `json:"into"` + // ToFieldPath is the context field path into which extra resources for this selector will be placed. + ToFieldPath string `json:"toFieldPath"` // FromFieldPath specifies a field path to extract from the object, instead of the whole object. FromFieldPath *string `json:"fromFieldPath,omitempty"` diff --git a/package/input/extra-resources.fn.crossplane.io_inputs.yaml b/package/input/extra-resources.fn.crossplane.io_inputs.yaml index 30cef0d..8cbdef6 100644 --- a/package/input/extra-resources.fn.crossplane.io_inputs.yaml +++ b/package/input/extra-resources.fn.crossplane.io_inputs.yaml @@ -57,10 +57,6 @@ spec: description: FromFieldPath specifies a field path to extract from the object, instead of the whole object. type: string - into: - description: Into is the key into which extra resources for - this selector will be placed. - type: string kind: description: Kind is the kubernetes kind of the target extra resource(s). @@ -144,6 +140,10 @@ spec: on which list of ExtraResources is alphabetically sorted. type: string type: object + toFieldPath: + description: ToFieldPath is the context field path into which + extra resources for this selector will be placed. + type: string type: default: Reference description: |- @@ -154,7 +154,7 @@ spec: - Selector type: string required: - - into + - toFieldPath type: object type: array policy: From 31bc7fbd9e96d04b2de63cf30fadba1fd237c578 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 09:24:01 +0100 Subject: [PATCH 06/14] test(to-fieldpath): fromFieldPath and toFieldPath Signed-off-by: Amund Tenstad --- fn_test.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/fn_test.go b/fn_test.go index 4e97fb5..5ef5758 100644 --- a/fn_test.go +++ b/fn_test.go @@ -600,8 +600,8 @@ func TestRunFunction(t *testing.T) { }, }, }, - "FromField": { - reason: "The Function should extract from FromFieldPath.", + "FromFieldPathAndToFieldPath": { + reason: "The Function should extract from FromFieldPath and put into ToFieldPath.", args: args{ req: &fnv1.RunFunctionRequest{ Meta: &fnv1.RequestMeta{Tag: "hello"}, @@ -617,7 +617,7 @@ func TestRunFunction(t *testing.T) { }, }, RequiredResources: map[string]*fnv1.Resources{ - "obj-0": { + "envconfs.names": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -652,10 +652,10 @@ func TestRunFunction(t *testing.T) { "spec": { "extraResources": [ { - "toFieldPath": "obj-0", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "fromFieldPath": "metadata.name", + "toFieldPath": "envconfs.names", "type": "Selector", "selector": { "matchLabels": [ @@ -678,7 +678,7 @@ func TestRunFunction(t *testing.T) { Results: []*fnv1.Result{}, Requirements: &fnv1.Requirements{ Resources: map[string]*fnv1.ResourceSelector{ - "obj-0": { + "envconfs.names": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchLabels{ @@ -693,11 +693,13 @@ func TestRunFunction(t *testing.T) { }, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - FunctionContextKeyExtraResources: structpb.NewStructValue(resource.MustStructJSON(`{ - "obj-0": [ - "first", - "second" - ] + "apiextensions.crossplane.io/extra-resources": structpb.NewStructValue(resource.MustStructJSON(`{ + "envconfs": { + "names": [ + "first", + "second" + ] + } }`)), }, }, From 3d3e71fb2392e270632b2106f14e6cdfcff4f73d Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 17:50:00 +0100 Subject: [PATCH 07/14] refactor(env-context): index based resource requirements key Signed-off-by: Amund Tenstad --- fn.go | 9 +++++---- fn_test.go | 38 +++++++++++++++++++------------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/fn.go b/fn.go index 7240ab4..9f81dd0 100644 --- a/fn.go +++ b/fn.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "reflect" "sort" @@ -114,8 +115,8 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) // from Crossplane's external resource API. func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Requirements, error) { //nolint:gocyclo // Adding non-nil validations increases function complexity. extraResources := make(map[string]*fnv1.ResourceSelector, len(in.Spec.ExtraResources)) - for _, extraResource := range in.Spec.ExtraResources { - extraResName := extraResource.ToFieldPath + for i, extraResource := range in.Spec.ExtraResources { + extraResName := fmt.Sprintf("resources-%d", i) switch extraResource.Type { case v1beta1.ResourceSourceTypeReference, "": extraResources[extraResName] = &fnv1.ResourceSelector{ @@ -169,8 +170,8 @@ func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Require func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource.Required, //nolint:gocyclo // TODO(reedjosh): refactor ) ([]FetchedResult, error) { results := []FetchedResult{} - for _, extraResource := range in.Spec.ExtraResources { - extraResName := extraResource.ToFieldPath + for i, extraResource := range in.Spec.ExtraResources { + extraResName := fmt.Sprintf("resources-%d", i) resources, ok := extraResources[extraResName] if !ok { return nil, errors.Errorf("cannot find expected extra resource %q", extraResName) diff --git a/fn_test.go b/fn_test.go index 5ef5758..b663b8c 100644 --- a/fn_test.go +++ b/fn_test.go @@ -159,21 +159,21 @@ func TestRunFunction(t *testing.T) { Results: []*fnv1.Result{}, Requirements: &fnv1.Requirements{ Resources: map[string]*fnv1.ResourceSelector{ - "obj-0": { + "resources-0": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchName{ MatchName: "my-env-config", }, }, - "obj-1": { + "resources-1": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchName{ MatchName: "my-second-env-config", }, }, - "obj-2": { + "resources-2": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchLabels{ @@ -187,7 +187,7 @@ func TestRunFunction(t *testing.T) { // // environment-config-3 is not requested because it was optional // - "obj-4": { + "resources-4": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchLabels{ @@ -198,7 +198,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-5": { + "resources-5": { ApiVersion: "test.crossplane.io/v1alpha1", Kind: "Foo", Match: &fnv1.ResourceSelector_MatchName{ @@ -206,7 +206,7 @@ func TestRunFunction(t *testing.T) { }, Namespace: ptr.To("my-namespace"), }, - "obj-6": { + "resources-6": { ApiVersion: "test.crossplane.io/v1alpha1", Kind: "Bar", Match: &fnv1.ResourceSelector_MatchLabels{ @@ -243,7 +243,7 @@ func TestRunFunction(t *testing.T) { }, }, RequiredResources: map[string]*fnv1.Resources{ - "obj-0": { + "resources-0": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -260,7 +260,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-1": { + "resources-1": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -277,7 +277,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-2": { + "resources-2": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -305,7 +305,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-3": { + "resources-3": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -321,7 +321,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-4": { + "resources-4": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -417,21 +417,21 @@ func TestRunFunction(t *testing.T) { Results: []*fnv1.Result{}, Requirements: &fnv1.Requirements{ Resources: map[string]*fnv1.ResourceSelector{ - "obj-0": { + "resources-0": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchName{ MatchName: "my-env-config", }, }, - "obj-1": { + "resources-1": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchName{ MatchName: "my-second-env-config", }, }, - "obj-2": { + "resources-2": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchLabels{ @@ -443,7 +443,7 @@ func TestRunFunction(t *testing.T) { }, }, // environment-config-3 is not requested because it was optional - "obj-4": { + "resources-4": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchLabels{ @@ -554,7 +554,7 @@ func TestRunFunction(t *testing.T) { }, }, RequiredResources: map[string]*fnv1.Resources{ - "environment-config-0": { + "resources-0": { Items: []*fnv1.Resource{}, }, }, @@ -588,7 +588,7 @@ func TestRunFunction(t *testing.T) { }, Requirements: &fnv1.Requirements{ Resources: map[string]*fnv1.ResourceSelector{ - "obj-0": { + "resources-0": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchName{ @@ -617,7 +617,7 @@ func TestRunFunction(t *testing.T) { }, }, RequiredResources: map[string]*fnv1.Resources{ - "envconfs.names": { + "resources-0": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -678,7 +678,7 @@ func TestRunFunction(t *testing.T) { Results: []*fnv1.Result{}, Requirements: &fnv1.Requirements{ Resources: map[string]*fnv1.ResourceSelector{ - "envconfs.names": { + "resources-0": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", Match: &fnv1.ResourceSelector_MatchLabels{ From 3897c279bf2481683cce52206322924f9ea46b4b Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 17:54:08 +0100 Subject: [PATCH 08/14] feat(env-context): configurable context key to put extra resources into Signed-off-by: Amund Tenstad --- fn.go | 7 +-- fn_test.go | 4 ++ input/v1beta1/resource_select.go | 46 +++++++++++++++++++ input/v1beta1/zz_generated.deepcopy.go | 30 ++++++++++++ ...tra-resources.fn.crossplane.io_inputs.yaml | 17 +++++++ 5 files changed, 98 insertions(+), 6 deletions(-) diff --git a/fn.go b/fn.go index 9f81dd0..8dbd349 100644 --- a/fn.go +++ b/fn.go @@ -20,11 +20,6 @@ import ( "github.com/crossplane-contrib/function-extra-resources/input/v1beta1" ) -// Key to retrieve extras at. -const ( - FunctionContextKeyExtraResources = "apiextensions.crossplane.io/extra-resources" -) - // Function returns whatever response you ask it to. type Function struct { fnv1.UnimplementedFunctionRunnerServiceServer @@ -106,7 +101,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - response.SetContextKey(rsp, FunctionContextKeyExtraResources, structpb.NewStructValue(s)) + response.SetContextKey(rsp, in.Spec.Into.GetIntoContextKey(), structpb.NewStructValue(s)) return rsp, nil } diff --git a/fn_test.go b/fn_test.go index b663b8c..35dfe73 100644 --- a/fn_test.go +++ b/fn_test.go @@ -19,6 +19,10 @@ import ( "github.com/crossplane/function-sdk-go/response" ) +const ( + FunctionContextKeyExtraResources = "apiextensions.crossplane.io/extra-resources" +) + func TestRunFunction(t *testing.T) { type args struct { ctx context.Context diff --git a/input/v1beta1/resource_select.go b/input/v1beta1/resource_select.go index f0df7af..b1522e4 100644 --- a/input/v1beta1/resource_select.go +++ b/input/v1beta1/resource_select.go @@ -20,6 +20,10 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" ) +const ( + FunctionContextKeyExtraResources = "apiextensions.crossplane.io/extra-resources" +) + // An InputSpec specifies extra resource(s) for rendering composed resources. type InputSpec struct { // ExtraResources selects a list of `ExtraResource`s. The resolved @@ -27,6 +31,9 @@ type InputSpec struct { // `spec.extraResourceRefs` and is only updated if it is null. ExtraResources []ResourceSource `json:"extraResources"` + // Into specifies how/where to store the extra resources. + Into *Into `json:"into,omitempty"` + // Policy represents the Resolution policies which apply to all // ResourceSourceReferences in ExtraResources list. // +optional @@ -223,3 +230,42 @@ func (pp *PatchPolicy) GetFromFieldPathPolicy() FromFieldPathPolicy { } return *pp.FromFieldPath } + +// An Into specifies how and where to return the extra resources results. +type Into struct { + // Type determines how to return the results. The default is to store the + // extra resources in a context key. + // +kubebuilder:validation:Enum=Context + // +kubebuilder:default=Context + Type *IntoType `json:"type,omitempty"` + + // ContextKey key to put extra resources into when Type is Context. + // +kubebuilder:default=apiextensions.crossplane.io/extra-resources + ContextKey *string `json:"contextKey,omitempty"` +} + +// An IntoType specifies how to return the extra resources results. +type IntoType string + +// IntoType types. +const ( + IntoTypeContext IntoType = "Context" +) + +// GetIntoType returns the Type for this Into, defaulting to IntoTypeContext if +// not specified. +func (i *Into) GetIntoType() IntoType { + if i == nil || i.Type == nil { + return IntoTypeContext + } + return *i.Type +} + +// GetIntoContextKey returns the ContextKey for this Into, defaulting to +// FunctionContextKeyExtraResources if not specified. +func (i *Into) GetIntoContextKey() string { + if i == nil || i.ContextKey == nil { + return FunctionContextKeyExtraResources + } + return *i.ContextKey +} diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index a5a7cdf..f47e4f6 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -45,6 +45,11 @@ func (in *InputSpec) DeepCopyInto(out *InputSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Into != nil { + in, out := &in.Into, &out.Into + *out = new(Into) + (*in).DeepCopyInto(*out) + } if in.Policy != nil { in, out := &in.Policy, &out.Policy *out = new(Policy) @@ -62,6 +67,31 @@ func (in *InputSpec) DeepCopy() *InputSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Into) DeepCopyInto(out *Into) { + *out = *in + if in.Type != nil { + in, out := &in.Type, &out.Type + *out = new(IntoType) + **out = **in + } + if in.ContextKey != nil { + in, out := &in.ContextKey, &out.ContextKey + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Into. +func (in *Into) DeepCopy() *Into { + if in == nil { + return nil + } + out := new(Into) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PatchPolicy) DeepCopyInto(out *PatchPolicy) { *out = *in diff --git a/package/input/extra-resources.fn.crossplane.io_inputs.yaml b/package/input/extra-resources.fn.crossplane.io_inputs.yaml index 8cbdef6..a9cbfec 100644 --- a/package/input/extra-resources.fn.crossplane.io_inputs.yaml +++ b/package/input/extra-resources.fn.crossplane.io_inputs.yaml @@ -157,6 +157,23 @@ spec: - toFieldPath type: object type: array + into: + description: Into specifies how/where to store the extra resources. + properties: + contextKey: + default: apiextensions.crossplane.io/extra-resources + description: ContextKey key to put extra resources into when Type + is Context. + type: string + type: + default: Context + description: |- + Type determines how to return the results. The default is to store the + extra resources in a context key. + enum: + - Context + type: string + type: object policy: description: |- Policy represents the Resolution policies which apply to all From 9b37eec42a31091c01f816b42d2699e30488d4b7 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 17:55:04 +0100 Subject: [PATCH 09/14] test(env-context): put extra resources into custom context key Signed-off-by: Amund Tenstad --- fn_test.go | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/fn_test.go b/fn_test.go index 35dfe73..477d614 100644 --- a/fn_test.go +++ b/fn_test.go @@ -710,6 +710,102 @@ func TestRunFunction(t *testing.T) { }, }, }, + "CustomIntoKey": { + reason: "The Function should put data into a custom context key when specified.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr" + }, + "spec": { + "existingEnvSelectorLabel": "someMoreBar" + } + }`), + }, + }, + RequiredResources: map[string]*fnv1.Resources{ + "resources-0": { + Items: []*fnv1.Resource{ + { + Resource: resource.MustStructJSON(`{ + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "my-env-config" + }, + "data": { + "firstKey": "firstVal" + } + }`), + }, + }, + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "extra-resources.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "into": { + "type": "Context", + "contextKey": "custom-key" + }, + "extraResources": [ + { + "type": "Reference", + "toFieldPath": "obj-0", + "kind": "EnvironmentConfig", + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "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{ + "resources-0": { + ApiVersion: "apiextensions.crossplane.io/v1beta1", + Kind: "EnvironmentConfig", + Match: &fnv1.ResourceSelector_MatchName{ + MatchName: "my-env-config", + }, + }, + }, + }, + Context: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "custom-key": structpb.NewStructValue(resource.MustStructJSON(`{ + "obj-0": [ + { + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "data": { + "firstKey": "firstVal" + }, + "kind": "EnvironmentConfig", + "metadata": { + "name": "my-env-config" + } + } + ] + }`)), + }, + }, + }, + }, + }, } for name, tc := range cases { From 180679d57fb45f5f9c34cdb7335e5ff3b31daaba Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 18:19:35 +0100 Subject: [PATCH 10/14] refactor(env-context): prepare for more into types Signed-off-by: Amund Tenstad --- fn.go | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/fn.go b/fn.go index 8dbd349..61f0eb4 100644 --- a/fn.go +++ b/fn.go @@ -87,12 +87,21 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - out := &unstructured.Unstructured{Object: map[string]interface{}{}} - for _, extras := range verifiedExtras { - if err := fieldpath.Pave(out.Object).SetValue(extras.source.ToFieldPath, extras.resources); err != nil { - response.Fatal(rsp, errors.Wrapf(err, "cannot set nested field path %q", extras.source.ToFieldPath)) - return rsp, nil - } + var out *unstructured.Unstructured + var key string + + t := in.Spec.Into.GetIntoType() + switch t { + case v1beta1.IntoTypeContext: + out, err = f.intoContext(verifiedExtras) + key = in.Spec.Into.GetIntoContextKey() + default: + err = errors.Errorf("unknown into type: %q", t) + } + + if err != nil { + response.Fatal(rsp, err) + return rsp, nil } s, err := resource.AsStruct(out) @@ -101,11 +110,22 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - response.SetContextKey(rsp, in.Spec.Into.GetIntoContextKey(), structpb.NewStructValue(s)) + response.SetContextKey(rsp, key, structpb.NewStructValue(s)) return rsp, nil } +func (f *Function) intoContext(verifiedExtras []FetchedResult) (*unstructured.Unstructured, error) { + out := &unstructured.Unstructured{Object: map[string]interface{}{}} + for _, extras := range verifiedExtras { + if err := fieldpath.Pave(out.Object).SetValue(extras.source.ToFieldPath, extras.resources); err != nil { + return nil, errors.Wrapf(err, "cannot set nested field path %q", extras.source.ToFieldPath) + } + } + + return out, nil +} + // Build requirements takes input and outputs an array of external resoruce requirements to request // from Crossplane's external resource API. func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Requirements, error) { //nolint:gocyclo // Adding non-nil validations increases function complexity. From aa80ff53f4d7b51ba477068a32fc69ff0a26ade3 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 17:55:27 +0100 Subject: [PATCH 11/14] chore(env-context): copy mergeMaps and KeyEnvironment from function-environment-configs Signed-off-by: Amund Tenstad --- fn.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/fn.go b/fn.go index 61f0eb4..0d27a7f 100644 --- a/fn.go +++ b/fn.go @@ -20,6 +20,12 @@ import ( "github.com/crossplane-contrib/function-extra-resources/input/v1beta1" ) +const ( + // FunctionContextKeyEnvironment is a well-known Context key where the computed Environment + // will be stored, so that Crossplane v1 and other functions can access it, e.g. function-patch-and-transform. + FunctionContextKeyEnvironment = "apiextensions.crossplane.io/environment" +) + // Function returns whatever response you ask it to. type Function struct { fnv1.UnimplementedFunctionRunnerServiceServer @@ -181,6 +187,25 @@ func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Require return &fnv1.Requirements{Resources: extraResources}, nil } +func mergeMaps(a, b map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(a)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + if v, ok := v.(map[string]interface{}); ok { + if bv, ok := out[k]; ok { + if bv, ok := bv.(map[string]interface{}); ok { + out[k] = mergeMaps(bv, v) + continue + } + } + } + out[k] = v + } + return out +} + // Verify Min/Max and sort extra resources by field path within a single kind. func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource.Required, //nolint:gocyclo // TODO(reedjosh): refactor ) ([]FetchedResult, error) { From 106a6df81983c3419b75200b98c27e6766f2ef65 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 18:08:09 +0100 Subject: [PATCH 12/14] feat(env-context): (part 1) partial support for loading into environment Signed-off-by: Amund Tenstad --- fn.go | 33 +++++++++++++++++-- input/v1beta1/resource_select.go | 8 +++-- input/v1beta1/zz_generated.deepcopy.go | 5 +++ ...tra-resources.fn.crossplane.io_inputs.yaml | 6 ++-- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/fn.go b/fn.go index 0d27a7f..56123f0 100644 --- a/fn.go +++ b/fn.go @@ -101,6 +101,9 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) case v1beta1.IntoTypeContext: out, err = f.intoContext(verifiedExtras) key = in.Spec.Into.GetIntoContextKey() + case v1beta1.IntoTypeEnvironment: + out, err = f.intoEnvironment(req, verifiedExtras) + key = FunctionContextKeyEnvironment default: err = errors.Errorf("unknown into type: %q", t) } @@ -124,8 +127,34 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) func (f *Function) intoContext(verifiedExtras []FetchedResult) (*unstructured.Unstructured, error) { out := &unstructured.Unstructured{Object: map[string]interface{}{}} for _, extras := range verifiedExtras { - if err := fieldpath.Pave(out.Object).SetValue(extras.source.ToFieldPath, extras.resources); err != nil { - return nil, errors.Wrapf(err, "cannot set nested field path %q", extras.source.ToFieldPath) + if toFieldPath := extras.source.ToFieldPath; toFieldPath != nil && *toFieldPath != "" { + if err := fieldpath.Pave(out.Object).SetValue(*toFieldPath, extras.resources); err != nil { + return nil, errors.Wrapf(err, "cannot set nested field path %q", *toFieldPath) + } + } else { + return nil, errors.New("must set toFieldPath for type Context") + } + } + + return out, nil +} + +func (f *Function) intoEnvironment(req *fnv1.RunFunctionRequest, verifiedExtras []FetchedResult) (*unstructured.Unstructured, error) { + mergedData := map[string]interface{}{} + for _, extras := range verifiedExtras { + for _, extra := range extras.resources { + if toFieldPath := extras.source.ToFieldPath; toFieldPath != nil && *toFieldPath != "" { + d := map[string]interface{}{} + if err := fieldpath.Pave(d).SetValue(*toFieldPath, extra); err != nil { + return nil, errors.Wrapf(err, "cannot set nested field path %q", *toFieldPath) + } + + mergedData = mergeMaps(mergedData, d) + } else if e, ok := extra.(map[string]interface{}); ok { + mergedData = mergeMaps(mergedData, e) + } else { + return nil, errors.New("must set toFieldPath when extracted value is not an object") + } } } diff --git a/input/v1beta1/resource_select.go b/input/v1beta1/resource_select.go index b1522e4..cd024ac 100644 --- a/input/v1beta1/resource_select.go +++ b/input/v1beta1/resource_select.go @@ -102,7 +102,8 @@ type ResourceSource struct { Namespace *string `json:"namespace,omitempty"` // ToFieldPath is the context field path into which extra resources for this selector will be placed. - ToFieldPath string `json:"toFieldPath"` + // Required when into.type is Context. + ToFieldPath *string `json:"toFieldPath"` // FromFieldPath specifies a field path to extract from the object, instead of the whole object. FromFieldPath *string `json:"fromFieldPath,omitempty"` @@ -235,7 +236,7 @@ func (pp *PatchPolicy) GetFromFieldPathPolicy() FromFieldPathPolicy { type Into struct { // Type determines how to return the results. The default is to store the // extra resources in a context key. - // +kubebuilder:validation:Enum=Context + // +kubebuilder:validation:Enum=Context;Environment // +kubebuilder:default=Context Type *IntoType `json:"type,omitempty"` @@ -249,7 +250,8 @@ type IntoType string // IntoType types. const ( - IntoTypeContext IntoType = "Context" + IntoTypeContext IntoType = "Context" + IntoTypeEnvironment IntoType = "Environment" ) // GetIntoType returns the Type for this Into, defaulting to IntoTypeContext if diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index f47e4f6..6f70fc8 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -155,6 +155,11 @@ func (in *ResourceSource) DeepCopyInto(out *ResourceSource) { *out = new(string) **out = **in } + if in.ToFieldPath != nil { + in, out := &in.ToFieldPath, &out.ToFieldPath + *out = new(string) + **out = **in + } if in.FromFieldPath != nil { in, out := &in.FromFieldPath, &out.FromFieldPath *out = new(string) diff --git a/package/input/extra-resources.fn.crossplane.io_inputs.yaml b/package/input/extra-resources.fn.crossplane.io_inputs.yaml index a9cbfec..fef8a7a 100644 --- a/package/input/extra-resources.fn.crossplane.io_inputs.yaml +++ b/package/input/extra-resources.fn.crossplane.io_inputs.yaml @@ -141,8 +141,9 @@ spec: type: string type: object toFieldPath: - description: ToFieldPath is the context field path into which - extra resources for this selector will be placed. + description: |- + ToFieldPath is the context field path into which extra resources for this selector will be placed. + Required when into.type is Context. type: string type: default: Reference @@ -172,6 +173,7 @@ spec: extra resources in a context key. enum: - Context + - Environment type: string type: object policy: From 8201184a6d61fdfb9f12adb86863101a13c9a976 Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 18:09:40 +0100 Subject: [PATCH 13/14] feat(env-context): (part 2) complete implementation by copying from function-environment-configs Signed-off-by: Amund Tenstad --- fn.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/fn.go b/fn.go index 56123f0..6947d36 100644 --- a/fn.go +++ b/fn.go @@ -8,6 +8,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" @@ -140,6 +141,15 @@ func (f *Function) intoContext(verifiedExtras []FetchedResult) (*unstructured.Un } func (f *Function) intoEnvironment(req *fnv1.RunFunctionRequest, verifiedExtras []FetchedResult) (*unstructured.Unstructured, error) { + var inputEnv *unstructured.Unstructured + if v, ok := request.GetContextKey(req, FunctionContextKeyEnvironment); ok { + inputEnv = &unstructured.Unstructured{} + if err := resource.AsObject(v.GetStructValue(), inputEnv); err != nil { + return nil, errors.Wrapf(err, "cannot get Composition environment from %T context key %q", req, FunctionContextKeyEnvironment) + } + f.log.Debug("Loaded Composition environment from Function context", "context-key", FunctionContextKeyEnvironment) + } + mergedData := map[string]interface{}{} for _, extras := range verifiedExtras { for _, extra := range extras.resources { @@ -158,6 +168,17 @@ func (f *Function) intoEnvironment(req *fnv1.RunFunctionRequest, verifiedExtras } } + // merge input env if any + if inputEnv != nil { + mergedData = mergeMaps(inputEnv.Object, mergedData) + } + + // build environment and return it in the response as context + out := &unstructured.Unstructured{Object: mergedData} + if out.GroupVersionKind().Empty() { + out.SetGroupVersionKind(schema.GroupVersionKind{Group: "internal.crossplane.io", Kind: "Environment", Version: "v1alpha1"}) + } + return out, nil } From 01e0c10b540eef5b6109a76acb23c791d7bf38bc Mon Sep 17 00:00:00 2001 From: Amund Tenstad Date: Wed, 11 Feb 2026 18:11:30 +0100 Subject: [PATCH 14/14] test(env-context): modify environment config test to put into environment Signed-off-by: Amund Tenstad --- fn_test.go | 98 +++++++++++------------------------------------------- 1 file changed, 20 insertions(+), 78 deletions(-) diff --git a/fn_test.go b/fn_test.go index 477d614..666da35 100644 --- a/fn_test.go +++ b/fn_test.go @@ -346,10 +346,13 @@ func TestRunFunction(t *testing.T) { "apiVersion": "extra-resources.fn.crossplane.io/v1beta1", "kind": "Input", "spec": { + "into": { + "type": "Environment" + }, "extraResources": [ { "type": "Reference", - "toFieldPath": "obj-0", + "fromFieldPath": "data", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "ref": { @@ -358,7 +361,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Reference", - "toFieldPath": "obj-1", + "fromFieldPath": "data", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "ref": { @@ -367,7 +370,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "toFieldPath": "obj-2", + "fromFieldPath": "data", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "selector": { @@ -382,7 +385,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "toFieldPath": "obj-3", + "fromFieldPath": "data", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "selector": { @@ -397,7 +400,8 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "toFieldPath": "obj-4", + "fromFieldPath": "data", + "toFieldPath": "nested", "apiVersion": "apiextensions.crossplane.io/v1beta1", "kind": "EnvironmentConfig", "selector": { @@ -462,79 +466,17 @@ func TestRunFunction(t *testing.T) { }, Context: &structpb.Struct{ Fields: map[string]*structpb.Value{ - FunctionContextKeyExtraResources: structpb.NewStructValue(resource.MustStructJSON(`{ - "obj-0": [ - { - "apiVersion": "apiextensions.crossplane.io/v1beta1", - "data": { - "firstKey": "firstVal", - "secondKey": "secondVal" - }, - "kind": "EnvironmentConfig", - "metadata": { - "name": "my-env-config" - } - } - ], - "obj-1": [ - { - "apiVersion": "apiextensions.crossplane.io/v1beta1", - "data": { - "secondKey": "secondVal-ok", - "thirdKey": "thirdVal" - }, - "kind": "EnvironmentConfig", - "metadata": { - "name": "my-second-env-config" - } - } - ], - "obj-2": [ - { - "apiVersion": "apiextensions.crossplane.io/v1beta1", - "data": { - "fourthKey": "fourthVal-a" - }, - "kind": "EnvironmentConfig", - "metadata": { - "name": "my-third-env-config-a" - } - }, - { - "apiVersion": "apiextensions.crossplane.io/v1beta1", - "data": { - "fourthKey": "fourthVal-b" - }, - "kind": "EnvironmentConfig", - "metadata": { - "name": "my-third-env-config-b" - } - } - ], - "obj-3": [ - { - "apiVersion": "apiextensions.crossplane.io/v1beta1", - "data": { - "fifthKey": "fifthVal" - }, - "kind": "EnvironmentConfig", - "metadata": { - "name": "my-third-env-config" - } - } - ], - "obj-4": [ - { - "apiVersion": "apiextensions.crossplane.io/v1beta1", - "data": { - "sixthKey": "sixthVal" - }, - "kind": "EnvironmentConfig", - "metadata": { - "name": "my-fourth-env-config" - } - } - ] + FunctionContextKeyEnvironment: structpb.NewStructValue(resource.MustStructJSON(`{ + "apiVersion": "internal.crossplane.io/v1alpha1", + "kind": "Environment", + "firstKey": "firstVal", + "secondKey": "secondVal-ok", + "thirdKey": "thirdVal", + "fourthKey": "fourthVal-b", + "fifthKey": "fifthVal", + "nested": { + "sixthKey": "sixthVal" + } }`)), }, },