From 65a63685892d479905d7c3ec86bebd7d9641a0cb Mon Sep 17 00:00:00 2001 From: John Gardiner Myers Date: Tue, 28 Oct 2025 23:23:40 -0700 Subject: [PATCH] feat: Implement Required Resources Signed-off-by: John Gardiner Myers --- README.md | 169 ++++++++++++- examples/default/required_resources/Makefile | 2 + examples/default/required_resources/README.md | 62 +++++ .../required_resources/composition.yaml | 53 ++++ .../default/required_resources/functions.yaml | 9 + .../required_resources.yaml | 29 +++ examples/default/required_resources/xr.yaml | 6 + .../required_resources_namespaced/Makefile | 2 + .../required_resources_namespaced/README.md | 61 +++++ .../composition.yaml | 54 +++++ .../functions.yaml | 9 + .../required_resources_namespaced.yaml | 31 +++ .../required_resources_namespaced/xr.yaml | 6 + fn.go | 22 +- fn_test.go | 228 ++++++++++++++++++ pkg/resource/requiredresources.go | 48 ++++ pkg/resource/res.go | 15 +- 17 files changed, 792 insertions(+), 14 deletions(-) create mode 100644 examples/default/required_resources/Makefile create mode 100644 examples/default/required_resources/README.md create mode 100644 examples/default/required_resources/composition.yaml create mode 100644 examples/default/required_resources/functions.yaml create mode 100644 examples/default/required_resources/required_resources.yaml create mode 100644 examples/default/required_resources/xr.yaml create mode 100644 examples/default/required_resources_namespaced/Makefile create mode 100644 examples/default/required_resources_namespaced/README.md create mode 100644 examples/default/required_resources_namespaced/composition.yaml create mode 100644 examples/default/required_resources_namespaced/functions.yaml create mode 100644 examples/default/required_resources_namespaced/required_resources_namespaced.yaml create mode 100644 examples/default/required_resources_namespaced/xr.yaml create mode 100644 pkg/resource/requiredresources.go diff --git a/README.md b/README.md index a2e9758..c226db5 100644 --- a/README.md +++ b/README.md @@ -529,16 +529,165 @@ spec: } ``` -### Extra resources -By defining one or more special `ExtraResources`, you can ask Crossplane to retrieve additional resources from the local cluster +### Required resources + +By defining one or more "required resources", you can ask Crossplane to retrieve additional resources from the local cluster and make them available to your templates. -See the [docs](https://github.com/crossplane/crossplane/blob/main/design/design-doc-composition-functions-extra-resources.md) for more information. +See the [docs](https://docs.crossplane.io/latest/composition/compositions/#required-resources) for more information. + +This feature only works with Crossplane v2. Crossplane v1 must use [Extra Resources](#extra-resources), described in the section below. + +There are two ways to request required resources: + +One, you can list the resources to retrieve in the `requirements.requiredResources` field +of the pipeline step: + +```yaml +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: example +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1beta1 + kind: XR + mode: Pipeline + pipeline: + - step: basic + functionRef: + name: function-kcl + requirements: + requiredResources: + - requirementName: foo + apiVersion: example.com/v1beta1 + kind: Foo + matchLabels: + foo: bar + - requirementName: bar + apiVersion: example.com/v1beta1 + kind: Bar + name: my-bar + - requirementName: baz + apiVersion: example.m.com/v1beta1 + kind: Bar + name: my-bar + namespace: my-baz-ns + - requirementName: quux + apiVersion: example.m.com/v1beta1 + kind: Quux + matchLabels: + baz: quux + namespace: my-quux-ns + input: + apiVersion: krm.kcl.dev/v1alpha1 + kind: KCLInput + spec: + source: "..." +``` + +Two, the composition can dynamically request resources by returning a special +`RequiredResources` item: + +```yaml +apiVersion: krm.kcl.dev/v1alpha1 +kind: KCLInput +spec: + source: | + # Omit other logic + details = { + apiVersion: "meta.krm.kcl.dev/v1alpha1" + kind: "RequiredResources" + requirements = { + foo = { + apiVersion: "example.com/v1beta1", + kind: "Foo", + matchLabels: { + "foo": "bar" + } + }, + bar = { + apiVersion: "example.com/v1beta1", + kind: "Bar", + name: "my-bar" + }, + baz = { + apiVersion: "example.m.com/v1beta1", + kind: "Bar", + name: "my-bar" + namespace: "my-baz-ns" + }, + quux = { + apiVersion: "example.m.com/v1beta1", + kind: "Quux", + matchLabels: { + "baz": "quux" + } + namespace: "my-quux-ns" + } + } + } + + # Omit other composite logics. + items = [ + details + # Omit other return resources. + ] +``` + +Either way will result in Crossplane retrieving the requested resources and making them available with the following format: + +```yaml +foo: +- Resource: + apiVersion: example.com/v1beta1 + kind: Foo + metadata: + labels: + foo: bar + # Omitted for brevity +- Resource: + apiVersion: example.com/v1beta1 + kind: Foo + metadata: + labels: + foo: bar + # Omit for brevity +bar: +- Resource: + apiVersion: example.com/v1beta1 + kind: Bar + metadata: + name: my-bar + # Omitted for brevity +``` + +You can access the retrieved resources in your code like this: > [!NOTE] -> With ExtraResources, you can fetch cluster-scoped resources, but not namespaced resources such as claims. -> If you need to get a composite resource via its claim name you can use `matchLabels` with `crossplane.io/claim-name: `. -> Namespace scoped resources can be queried with the `matchNamespace` field. -> Leaving the `matchNamespace` field empty or not defining it will query a cluster scoped resource. +> Crossplane performs an additional reconciliation pass for dynamic required resources. +> Consequently, during the initial execution, these resources might not be present. +> It is essential to implement checks to handle this scenario. + +```yaml +apiVersion: krm.kcl.dev/v1alpha1 +kind: KCLInput +spec: + source: | + er = option("params")?.requiredResources + + if er?.bar: + name = er?.bar[0]?.Resource?.metadata?.name or "" + # Omit other logic +``` + +### Extra resources + +Extra resources are Crossplane v1's mechanism for retrieving additional resources from the local cluster. +It is deprecated in Crossplane v2. + +Unlike required resources, there is no mechanism for requesting extra resources in the +pipeline step definition. They can only be requested dynamically, by returning a special +`ExtraResources` item: ```yaml apiVersion: krm.kcl.dev/v1alpha1 @@ -587,7 +736,9 @@ spec: ``` You can retrieve the extra resources either via labels with `matchLabels` or via name with `matchName: somename`. -This will result in Crossplane receiving the requested resources and make them available with the following format. +See the [docs](https://github.com/crossplane/crossplane/blob/main/design/design-doc-composition-functions-extra-resources.md) for more information. + +This will result in Crossplane receiving the requested resources and making them available using the following format: ```yaml foo: @@ -618,7 +769,7 @@ You can access the retrieved resources in your code like this: > [!NOTE] > Crossplane performs an additional reconciliation pass for extra resources. -> Consequently, during the initial execution, these resources may be uninitialized. +> Consequently, during the initial execution, these resources might not be present. > It is essential to implement checks to handle this scenario. ```yaml diff --git a/examples/default/required_resources/Makefile b/examples/default/required_resources/Makefile new file mode 100644 index 0000000..5fe8e6c --- /dev/null +++ b/examples/default/required_resources/Makefile @@ -0,0 +1,2 @@ +run: + crossplane render --verbose xr.yaml composition.yaml functions.yaml -r --required-resources required_resources.yaml diff --git a/examples/default/required_resources/README.md b/examples/default/required_resources/README.md new file mode 100644 index 0000000..5a4df07 --- /dev/null +++ b/examples/default/required_resources/README.md @@ -0,0 +1,62 @@ +# Example Manifests + +You can run your function locally and test it using `crossplane render` +with these example manifests. + +```shell +# Run the function locally +$ go run . --insecure --debug +``` + +```shell +# Then, in another terminal, call it with these example manifests +$ crossplane render --verbose xr.yaml composition.yaml functions.yaml -r --required-resources required_resources.yaml +--- +--- +apiVersion: example.crossplane.io/v1beta1 +kind: XR +metadata: + name: example +status: + conditions: + - lastTransitionTime: "2024-01-01T00:00:00Z" + message: 'Unready resources: another-awesome-dev-bucket, my-awesome-dev-bucket' + reason: Creating + status: "False" + type: Ready +--- +apiVersion: example/v1alpha1 +kind: Foo +metadata: + annotations: + crossplane.io/composition-resource-name: another-awesome-dev-bucket + generateName: example- + labels: + crossplane.io/composite: example + name: another-awesome-dev-bucket + ownerReferences: + - apiVersion: example.crossplane.io/v1beta1 + blockOwnerDeletion: true + controller: true + kind: XR + name: example + uid: "" +--- +apiVersion: example/v1alpha1 +kind: Foo +metadata: + annotations: + crossplane.io/composition-resource-name: my-awesome-dev-bucket + generateName: example- + labels: + crossplane.io/composite: example + name: my-awesome-dev-bucket + ownerReferences: + - apiVersion: example.crossplane.io/v1beta1 + blockOwnerDeletion: true + controller: true + kind: XR + name: example + uid: "" + +``` diff --git a/examples/default/required_resources/composition.yaml b/examples/default/required_resources/composition.yaml new file mode 100644 index 0000000..ad68389 --- /dev/null +++ b/examples/default/required_resources/composition.yaml @@ -0,0 +1,53 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: kcl-function +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1beta1 + kind: XR + mode: Pipeline + pipeline: + - step: normal + functionRef: + name: kcl-function + input: + apiVersion: krm.kcl.dev/v1alpha1 + kind: KCLInput + metadata: + annotations: + "krm.kcl.dev/default_ready": "True" + spec: + source: | + oxr = option("params").oxr + rr = option("params")?.requiredResources + + foo = [{ + apiVersion: "example/v1alpha1" + kind: "Foo" + metadata = { + name: k.Resource.metadata.name + } + } for k in rr?.bucket] if rr?.bucket else [] + + dxr = { + **oxr + } + + details = { + apiVersion: "meta.krm.kcl.dev/v1alpha1" + kind: "RequiredResources" + requirements = { + bucket = { + apiVersion: "s3.aws.upbound.io/v1beta1", + kind: "Bucket", + matchLabels: { + "foo": "bar" + } + } + } + } + items = [ + details + dxr + ] + foo diff --git a/examples/default/required_resources/functions.yaml b/examples/default/required_resources/functions.yaml new file mode 100644 index 0000000..d5679cb --- /dev/null +++ b/examples/default/required_resources/functions.yaml @@ -0,0 +1,9 @@ +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: kcl-function + annotations: + # This tells crossplane render to connect to the function locally. + render.crossplane.io/runtime: Development +spec: + package: xpkg.upbound.io/crossplane-contrib/function-kcl:latest diff --git a/examples/default/required_resources/required_resources.yaml b/examples/default/required_resources/required_resources.yaml new file mode 100644 index 0000000..8d5a780 --- /dev/null +++ b/examples/default/required_resources/required_resources.yaml @@ -0,0 +1,29 @@ +apiVersion: s3.aws.upbound.io/v1beta1 +kind: Bucket +metadata: + annotations: + crossplane.io/external-name: my-awesome-dev-bucket + labels: + foo: bar + name: my-awesome-dev-bucket +spec: + forProvider: + region: us-west-1 +status: + atProvider: + id: random-bucket-id +--- +apiVersion: s3.aws.upbound.io/v1beta1 +kind: Bucket +metadata: + annotations: + crossplane.io/external-name: my-awesome-dev-bucket + labels: + foo: bar + name: another-awesome-dev-bucket +spec: + forProvider: + region: us-west-1 +status: + atProvider: + id: random-bucket-id diff --git a/examples/default/required_resources/xr.yaml b/examples/default/required_resources/xr.yaml new file mode 100644 index 0000000..67aa59f --- /dev/null +++ b/examples/default/required_resources/xr.yaml @@ -0,0 +1,6 @@ +apiVersion: example.crossplane.io/v1beta1 +kind: XR +metadata: + name: example +spec: + count: 1 diff --git a/examples/default/required_resources_namespaced/Makefile b/examples/default/required_resources_namespaced/Makefile new file mode 100644 index 0000000..824df40 --- /dev/null +++ b/examples/default/required_resources_namespaced/Makefile @@ -0,0 +1,2 @@ +run: + crossplane render --verbose xr.yaml composition.yaml functions.yaml -r --required-resources required_resources_namespaced.yaml diff --git a/examples/default/required_resources_namespaced/README.md b/examples/default/required_resources_namespaced/README.md new file mode 100644 index 0000000..1183198 --- /dev/null +++ b/examples/default/required_resources_namespaced/README.md @@ -0,0 +1,61 @@ +# Example Manifests + +You can run your function locally and test it using `crossplane render` +with these example manifests. + +```shell +# Run the function locally +$ go run . --insecure --debug +``` + +```shell +# Then, in another terminal, call it with these example manifests +$ crossplane render --verbose xr.yaml composition.yaml functions.yaml -r --required-resources required_resources_namespaced.yaml +--- +--- +apiVersion: example.crossplane.io/v1beta1 +kind: XR +metadata: + name: example +status: + conditions: + - lastTransitionTime: "2024-01-01T00:00:00Z" + message: 'Unready resources: another-awesome-dev-bucket, my-awesome-dev-bucket' + reason: Creating + status: "False" + type: Ready +--- +apiVersion: example/v1alpha1 +kind: Foo +metadata: + annotations: + crossplane.io/composition-resource-name: another-awesome-dev-bucket + generateName: example- + labels: + crossplane.io/composite: example + name: another-awesome-dev-bucket + ownerReferences: + - apiVersion: example.crossplane.io/v1beta1 + blockOwnerDeletion: true + controller: true + kind: XR + name: example + uid: "" +--- +apiVersion: example/v1alpha1 +kind: Foo +metadata: + annotations: + crossplane.io/composition-resource-name: my-awesome-dev-bucket + generateName: example- + labels: + crossplane.io/composite: example + name: my-awesome-dev-bucket + ownerReferences: + - apiVersion: example.crossplane.io/v1beta1 + blockOwnerDeletion: true + controller: true + kind: XR + name: example + uid: "" +``` diff --git a/examples/default/required_resources_namespaced/composition.yaml b/examples/default/required_resources_namespaced/composition.yaml new file mode 100644 index 0000000..91f01ab --- /dev/null +++ b/examples/default/required_resources_namespaced/composition.yaml @@ -0,0 +1,54 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: kcl-function +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1beta1 + kind: XR + mode: Pipeline + pipeline: + - step: normal + functionRef: + name: kcl-function + input: + apiVersion: krm.kcl.dev/v1alpha1 + kind: KCLInput + metadata: + annotations: + "krm.kcl.dev/default_ready": "True" + spec: + source: | + oxr = option("params").oxr + rr = option("params")?.requiredResources + + foo = [{ + apiVersion: "example/v1alpha1" + kind: "Foo" + metadata = { + name: k.Resource.metadata.name + } + } for k in rr?.bucket] if rr?.bucket else [] + + dxr = { + **oxr + } + + details = { + apiVersion: "meta.krm.kcl.dev/v1alpha1" + kind: "RequiredResources" + requirements = { + bucket = { + apiVersion: "s3.aws.m.upbound.io/v1beta1", + kind: "Bucket", + matchLabels: { + "foo": "bar" + } + matchNamespace: "awesome-namespace" + } + } + } + items = [ + details + dxr + ] + foo diff --git a/examples/default/required_resources_namespaced/functions.yaml b/examples/default/required_resources_namespaced/functions.yaml new file mode 100644 index 0000000..d5679cb --- /dev/null +++ b/examples/default/required_resources_namespaced/functions.yaml @@ -0,0 +1,9 @@ +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: kcl-function + annotations: + # This tells crossplane render to connect to the function locally. + render.crossplane.io/runtime: Development +spec: + package: xpkg.upbound.io/crossplane-contrib/function-kcl:latest diff --git a/examples/default/required_resources_namespaced/required_resources_namespaced.yaml b/examples/default/required_resources_namespaced/required_resources_namespaced.yaml new file mode 100644 index 0000000..aaaab1f --- /dev/null +++ b/examples/default/required_resources_namespaced/required_resources_namespaced.yaml @@ -0,0 +1,31 @@ +apiVersion: s3.aws.m.upbound.io/v1beta1 +kind: Bucket +metadata: + annotations: + crossplane.io/external-name: my-awesome-dev-bucket + labels: + foo: bar + name: my-awesome-dev-bucket + namespace: awesome-namespace +spec: + forProvider: + region: us-west-1 +status: + atProvider: + id: random-bucket-id +--- +apiVersion: s3.aws.m.upbound.io/v1beta1 +kind: Bucket +metadata: + annotations: + crossplane.io/external-name: my-awesome-dev-bucket + labels: + foo: bar + name: another-awesome-dev-bucket + namespace: awesome-namespace +spec: + forProvider: + region: us-west-1 +status: + atProvider: + id: random-bucket-id diff --git a/examples/default/required_resources_namespaced/xr.yaml b/examples/default/required_resources_namespaced/xr.yaml new file mode 100644 index 0000000..497db4f --- /dev/null +++ b/examples/default/required_resources_namespaced/xr.yaml @@ -0,0 +1,6 @@ +apiVersion: example.crossplane.io/v1beta1 +kind: XR +metadata: + name: example +spec: + count: 1 \ No newline at end of file diff --git a/fn.go b/fn.go index 1bafd16..81af7a3 100644 --- a/fn.go +++ b/fn.go @@ -163,6 +163,18 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) response.Fatal(rsp, err) return rsp, nil } + // The required resources by myself or any previous Functions in the pipeline. + required, err := request.GetRequiredResources(req) + if err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot get required resources from %T", req)) + return rsp, nil + } + log.Debug(fmt.Sprintf("Required resources: %d", len(required))) + in.Spec.Params["requiredResources"], err = pkgresource.ObjToRawExtension(required) + if err != nil { + response.Fatal(rsp, err) + return rsp, nil + } inputBytes, outputBytes := bytes.NewBuffer([]byte{}), bytes.NewBuffer([]byte{}) // Convert the function-kcl KCLInput to the KRM-KCL spec and run function pipelines. // Input Example: https://github.com/kcl-lang/krm-kcl/blob/main/examples/mutation/set-annotations/suite/good.yaml @@ -204,10 +216,11 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) } log.Debug(fmt.Sprintf("Input resources: %v", resources)) extraResources := map[string]*fnv1.ResourceSelector{} + requiredResources := map[string]*fnv1.ResourceSelector{} var conditions pkgresource.ConditionResources var events pkgresource.EventResources contextData := make(map[string]interface{}) - result, err := pkgresource.ProcessResources(dxr, oxr, desired, observed, extraResources, &conditions, &events, &contextData, in.Spec.Target, resources, &pkgresource.AddResourcesOptions{ + result, err := pkgresource.ProcessResources(dxr, oxr, desired, observed, extraResources, requiredResources, &conditions, &events, &contextData, in.Spec.Target, resources, &pkgresource.AddResourcesOptions{ Basename: in.Name, Data: data, Overwrite: true, @@ -216,11 +229,14 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) response.Fatal(rsp, errors.Wrapf(err, "cannot process xr and state with the pipeline output in %T", rsp)) return rsp, nil } - if len(extraResources) > 0 { + if len(extraResources) > 0 || len(requiredResources) > 0 { for n, d := range extraResources { log.Debug(fmt.Sprintf("Requesting ExtraResources from %s named %s", d.String(), n)) } - rsp.Requirements = &fnv1.Requirements{ExtraResources: extraResources} + for n, d := range requiredResources { + log.Debug(fmt.Sprintf("Requesting RequiredResources from %s named %s", d.String(), n)) + } + rsp.Requirements = &fnv1.Requirements{ExtraResources: extraResources, Resources: requiredResources} } if len(conditions) > 0 { diff --git a/fn_test.go b/fn_test.go index 7b832ab..5ec3432 100644 --- a/fn_test.go +++ b/fn_test.go @@ -428,6 +428,234 @@ func TestRunFunctionSimple(t *testing.T) { }, }, }, + "RequiredResources": { + reason: "The Function should return the desired composite with required resources.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "required-resources"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "krm.kcl.dev/v1alpha1", + "kind": "KCLInput", + "metadata": { + "name": "basic" + }, + "spec": { + "target": "Default", + "source": "items = [\n{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"RequiredResources\"\n requirements = {\n \"cool-required-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolRequiredResource\"\n name: \"cool-required-resource\"\n }\n }\n},\n{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"RequiredResources\"\n requirements = {\n \"another-cool-required-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolRequiredResource\"\n matchLabels = {\n key: \"value\"\n }\n }\n \"yet-another-cool-required-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolRequiredResource\"\n name: \"foo\"\n }\n }\n},\n{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"RequiredResources\"\n requirements = {\n \"all-cool-resources\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolRequiredResource\"\n matchLabels = {}\n }\n }\n}\n]\n" + } + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{ + "cool-cd": { + Resource: resource.MustStructJSON(cd), + }, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "required-resources", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ + Resources: map[string]*fnv1.ResourceSelector{ + "cool-required-resource": { + ApiVersion: "example.org/v1", + Kind: "CoolRequiredResource", + Match: &fnv1.ResourceSelector_MatchName{ + MatchName: "cool-required-resource", + }, + }, + "another-cool-required-resource": { + ApiVersion: "example.org/v1", + Kind: "CoolRequiredResource", + Match: &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{ + Labels: map[string]string{"key": "value"}, + }, + }, + }, + "yet-another-cool-required-resource": { + ApiVersion: "example.org/v1", + Kind: "CoolRequiredResource", + Match: &fnv1.ResourceSelector_MatchName{ + MatchName: "foo", + }, + }, + "all-cool-resources": { + ApiVersion: "example.org/v1", + Kind: "CoolRequiredResource", + Match: &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{ + Labels: map[string]string{}, + }, + }, + }, + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{ + "cool-cd": { + Resource: resource.MustStructJSON(cd), + }, + }, + }, + }, + }, + }, + "RequiredResourcesNamespacedName": { + reason: "The Function should pass through a single required namespaced resource with name and namespace", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "required-resources-namespace-matchname"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "krm.kcl.dev/v1alpha1", + "kind": "KCLInput", + "metadata": {"name": "basic"}, + "spec": { + "target": "Default", + "source": "items = [{ apiVersion: \"meta.krm.kcl.dev/v1alpha1\", kind: \"RequiredResources\", requirements = { \"cool-ns-resource-matchname\" = { apiVersion: \"example.m.org/v1\", kind: \"CoolRequiredResource\", namespace: \"cool-ns-scoped-ns\", name: \"cool-ns-scoped-resource\" } } }]" + } + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{Resource: resource.MustStructJSON(xr)}, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{Resource: resource.MustStructJSON(xr)}, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "required-resources-namespace-matchname", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ + Resources: map[string]*fnv1.ResourceSelector{ + "cool-ns-resource-matchname": { + ApiVersion: "example.m.org/v1", + Kind: "CoolRequiredResource", + Namespace: ptr.To[string]("cool-ns-scoped-ns"), + Match: &fnv1.ResourceSelector_MatchName{MatchName: "cool-ns-scoped-resource"}, + }, + }, + }, + Desired: &fnv1.State{Composite: &fnv1.Resource{Resource: resource.MustStructJSON(xr)}}, + }, + }, + }, + "RequiredResourcesIn": { + reason: "The Function should return the required resources from the request.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "required-resources-in"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "krm.kcl.dev/v1alpha1", + "kind": "KCLInput", + "metadata": { + "name": "basic" + }, + "spec": { + "target": "Default", + "source": "items = [v.Resource for v in option(\"params\").requiredResources[\"cool1\"]]\n" + } + }`), + RequiredResources: map[string]*fnv1.Resources{ + "cool1": { + Items: []*fnv1.Resource{ + {Resource: resource.MustStructJSON(xr)}, + {Resource: resource.MustStructJSON(cd)}, + }, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "required-resources-in", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{"apiVersion":"","kind":""}`), + }, + Resources: map[string]*fnv1.Resource{ + "cool-xr": { + Resource: resource.MustStructJSON(xr), + }, + "cool-cd": { + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","metadata":{"annotations":{},"name":"cool-cd"}}`), + }, + }, + }, + }, + }, + }, + "DuplicateRequiredResourceKey": { + reason: "The Function should return a fatal result if the required resource key is duplicated.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "duplicate-required-resources"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "krm.kcl.dev/v1alpha1", + "kind": "KCLInput", + "metadata": { + "name": "basic" + }, + "spec": { + "target": "Default", + "source": "items = [\n{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"RequiredResources\"\n requirements = {\n \"cool-required-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolRequiredResource\"\n name: \"cool-required-resource\"\n }\n }\n}\n{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"RequiredResources\"\n requirements = {\n \"cool-required-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolRequiredResource\"\n name: \"another-cool-required-resource\"\n }\n }\n}\n]\n" + } + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{ + "cool-cd": { + Resource: resource.MustStructJSON(cd), + }, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "duplicate-required-resources", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + Message: "cannot process xr and state with the pipeline output in *v1.RunFunctionResponse: duplicate required resource key \"cool-required-resource\"", + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{ + "cool-cd": { + Resource: resource.MustStructJSON(cd), + }, + }, + }, + }, + }, + }, "EmptyInputWithDefaultSource": { reason: "The function should use the default source when input is not provided and default source is set", defaultSource: "{\n apiVersion: \"example.org/v1\"\n kind: \"Generated\"\n}", diff --git a/pkg/resource/requiredresources.go b/pkg/resource/requiredresources.go new file mode 100644 index 0000000..7cb1f78 --- /dev/null +++ b/pkg/resource/requiredresources.go @@ -0,0 +1,48 @@ +package resource + +import ( + fnv1 "github.com/crossplane/function-sdk-go/proto/v1" +) + +// RequiredResourcesRequirements defines the requirements for required resources. +type RequiredResourcesRequirements map[string]RequiredResourcesRequirement + +// RequiredResourcesRequirement defines a single requirement for required resources. +// Needed to have camelCase keys instead of the snake_case keys as defined +// through json tags by fnv1.ResourceSelector. +type RequiredResourcesRequirement struct { + // APIVersion of the resource. + APIVersion string `json:"apiVersion"` + // Kind of the resource. + Kind string `json:"kind"` + // MatchLabels defines the labels to match the resource, if Name is empty. + MatchLabels map[string]string `json:"matchLabels,omitempty"` + // Name defines the name to match the resource. + // If defined, MatchLabels is ignored. + Name string `json:"name,omitempty"` + // Namespace defines the namespace to match a namespace scoped resource, if set. + // If empty, the resource is assumed to be cluster scoped. + Namespace string `json:"namespace,omitempty"` +} + +// ToResourceSelector converts the RequiredResourcesRequirement to a fnv1.ResourceSelector. +func (e *RequiredResourcesRequirement) ToResourceSelector() *fnv1.ResourceSelector { + out := &fnv1.ResourceSelector{ + ApiVersion: e.APIVersion, + Kind: e.Kind, + } + if e.Namespace != "" { + out.Namespace = &e.Namespace + } + if e.Name == "" { + out.Match = &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{Labels: e.MatchLabels}, + } + return out + } + + out.Match = &fnv1.ResourceSelector_MatchName{ + MatchName: e.Name, + } + return out +} diff --git a/pkg/resource/res.go b/pkg/resource/res.go index 8517964..74cf26f 100644 --- a/pkg/resource/res.go +++ b/pkg/resource/res.go @@ -387,8 +387,7 @@ func SetData(data any, path string, o any, overwrite bool) error { return nil } -func ProcessResources(dxr *resource.Composite, oxr *resource.Composite, desired map[resource.Name]*resource.DesiredComposed, observed map[resource.Name]resource.ObservedComposed, extraResources map[string]*fnv1.ResourceSelector, conditions *ConditionResources, - events *EventResources, contextData *map[string]interface{}, target Target, resources ResourceList, opts *AddResourcesOptions) (AddResourcesResult, error) { +func ProcessResources(dxr *resource.Composite, oxr *resource.Composite, desired map[resource.Name]*resource.DesiredComposed, observed map[resource.Name]resource.ObservedComposed, extraResources map[string]*fnv1.ResourceSelector, requiredResources map[string]*fnv1.ResourceSelector, conditions *ConditionResources, events *EventResources, contextData *map[string]interface{}, target Target, resources ResourceList, opts *AddResourcesOptions) (AddResourcesResult, error) { result := AddResourcesResult{ Target: target, } @@ -512,6 +511,18 @@ func ProcessResources(dxr *resource.Composite, oxr *resource.Composite, desired } extraResources[k] = v.ToResourceSelector() } + case "RequiredResources": + // Set required resources requirements. + rrs := make(RequiredResourcesRequirements) + if err := cd.Resource.GetValueInto("requirements", &rrs); err != nil { + return result, errors.Wrap(err, "cannot get required resources requirements") + } + for k, v := range rrs { + if _, found := requiredResources[k]; found { + return result, errors.Errorf("duplicate required resource key %q", k) + } + requiredResources[k] = v.ToResourceSelector() + } case "Conditions": // Returns conditions to add to the claim / composite if err := cd.Resource.GetValueInto("conditions", conditions); err != nil {