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 f72ce1d..6947d36 100644 --- a/fn.go +++ b/fn.go @@ -2,13 +2,13 @@ package main import ( "context" - "encoding/json" + "fmt" "reflect" "sort" - "google.golang.org/protobuf/encoding/protojson" "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" @@ -21,9 +21,10 @@ import ( "github.com/crossplane-contrib/function-extra-resources/input/v1beta1" ) -// Key to retrieve extras at. const ( - FunctionContextKeyExtraResources = "apiextensions.crossplane.io/extra-resources" + // 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. @@ -33,6 +34,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()) @@ -88,38 +94,100 @@ 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) + 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() + case v1beta1.IntoTypeEnvironment: + out, err = f.intoEnvironment(req, verifiedExtras) + key = FunctionContextKeyEnvironment + default: + err = errors.Errorf("unknown into type: %q", t) + } + if err != nil { - response.Fatal(rsp, errors.Errorf("cannot marshal %T: %w", verifiedExtras, err)) + response.Fatal(rsp, err) 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)) + + 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 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) { + 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 { + 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") + } + } + } + + // 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 +} + // 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. extraResources := make(map[string]*fnv1.ResourceSelector, len(in.Spec.ExtraResources)) - for _, extraResource := range in.Spec.ExtraResources { - extraResName := extraResource.Into + for i, extraResource := range in.Spec.ExtraResources { + extraResName := fmt.Sprintf("resources-%d", i) switch extraResource.Type { case v1beta1.ResourceSourceTypeReference, "": extraResources[extraResName] = &fnv1.ResourceSelector{ @@ -169,16 +237,36 @@ 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 -) (cleanedExtras map[string][]unstructured.Unstructured, err error) { - cleanedExtras = make(map[string][]unstructured.Unstructured) - for _, extraResource := range in.Spec.ExtraResources { - extraResName := extraResource.Into +) ([]FetchedResult, error) { + results := []FetchedResult{} + 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) } + switch extraResource.GetType() { case v1beta1.ResourceSourceTypeReference: if len(resources) == 0 { @@ -190,7 +278,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,12 +290,28 @@ 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) + } + + result := FetchedResult{source: extraResource} + for _, r := range resources { + 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/fn_test.go b/fn_test.go index 6f0b726..666da35 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 @@ -63,7 +67,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 +76,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 +85,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 +100,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 +115,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 +131,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 +141,7 @@ func TestRunFunction(t *testing.T) { "kind": "Bar", "apiVersion": "test.crossplane.io/v1alpha1", "namespace": "my-namespace", - "into": "obj-6", + "toFieldPath": "obj-6", "selector": { "matchLabels": [ { @@ -159,21 +163,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 +191,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 +202,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-5": { + "resources-5": { ApiVersion: "test.crossplane.io/v1alpha1", Kind: "Foo", Match: &fnv1.ResourceSelector_MatchName{ @@ -206,7 +210,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 +247,7 @@ func TestRunFunction(t *testing.T) { }, }, RequiredResources: map[string]*fnv1.Resources{ - "obj-0": { + "resources-0": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -260,7 +264,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-1": { + "resources-1": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -277,7 +281,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-2": { + "resources-2": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -305,7 +309,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-3": { + "resources-3": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -321,7 +325,7 @@ func TestRunFunction(t *testing.T) { }, }, }, - "obj-4": { + "resources-4": { Items: []*fnv1.Resource{ { Resource: resource.MustStructJSON(`{ @@ -342,10 +346,13 @@ func TestRunFunction(t *testing.T) { "apiVersion": "extra-resources.fn.crossplane.io/v1beta1", "kind": "Input", "spec": { + "into": { + "type": "Environment" + }, "extraResources": [ { "type": "Reference", - "into": "obj-0", + "fromFieldPath": "data", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "ref": { @@ -354,7 +361,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Reference", - "into": "obj-1", + "fromFieldPath": "data", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "ref": { @@ -363,7 +370,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "into": "obj-2", + "fromFieldPath": "data", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "selector": { @@ -378,7 +385,7 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "into": "obj-3", + "fromFieldPath": "data", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "selector": { @@ -393,7 +400,8 @@ func TestRunFunction(t *testing.T) { }, { "type": "Selector", - "into": "obj-4", + "fromFieldPath": "data", + "toFieldPath": "nested", "apiVersion": "apiextensions.crossplane.io/v1beta1", "kind": "EnvironmentConfig", "selector": { @@ -417,21 +425,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 +451,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{ @@ -458,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" + } }`)), }, }, @@ -554,7 +500,7 @@ func TestRunFunction(t *testing.T) { }, }, RequiredResources: map[string]*fnv1.Resources{ - "environment-config-0": { + "resources-0": { Items: []*fnv1.Resource{}, }, }, @@ -565,7 +511,7 @@ func TestRunFunction(t *testing.T) { "extraResources": [ { "type": "Reference", - "into": "obj-0", + "toFieldPath": "obj-0", "kind": "EnvironmentConfig", "apiVersion": "apiextensions.crossplane.io/v1beta1", "ref": { @@ -588,7 +534,191 @@ 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{ + MatchName: "my-env-config", + }, + }, + }, + }, + }, + }, + }, + "FromFieldPathAndToFieldPath": { + reason: "The Function should extract from FromFieldPath and put into ToFieldPath.", + 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{ + "resources-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": [ + { + "kind": "EnvironmentConfig", + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "fromFieldPath": "metadata.name", + "toFieldPath": "envconfs.names", + "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{ + "resources-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{ + "apiextensions.crossplane.io/extra-resources": structpb.NewStructValue(resource.MustStructJSON(`{ + "envconfs": { + "names": [ + "first", + "second" + ] + } + }`)), + }, + }, + }, + }, + }, + "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{ @@ -597,6 +727,24 @@ func TestRunFunction(t *testing.T) { }, }, }, + 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" + } + } + ] + }`)), + }, + }, }, }, }, diff --git a/input/v1beta1/resource_select.go b/input/v1beta1/resource_select.go index 224bba2..cd024ac 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 @@ -94,8 +101,12 @@ 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. + // 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"` } // GetType returns the type of the resource source, returning the default if not set. @@ -220,3 +231,43 @@ 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;Environment + // +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" + IntoTypeEnvironment IntoType = "Environment" +) + +// 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 1a9813d..6f70fc8 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 @@ -125,6 +155,16 @@ 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) + **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..fef8a7a 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,9 +53,9 @@ spec: description: APIVersion is the kubernetes API Version of the target extra resource(s). type: string - into: - description: Into is the key into which extra resources for - this selector will be placed. + fromFieldPath: + description: FromFieldPath specifies a field path to extract + from the object, instead of the whole object. type: string kind: description: Kind is the kubernetes kind of the target extra @@ -140,6 +140,11 @@ 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. + Required when into.type is Context. + type: string type: default: Reference description: |- @@ -150,9 +155,27 @@ spec: - Selector type: string required: - - into + - 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 + - Environment + type: string + type: object policy: description: |- Policy represents the Resolution policies which apply to all