From 4d865e94c3797806f35d9d9f2e233d3e0c392b32 Mon Sep 17 00:00:00 2001 From: Michael Fraenkel Date: Fri, 27 Sep 2024 18:46:39 -0600 Subject: [PATCH] feat: add support for extra resources Provide the ability to read the extra resources from the request using option["params"].extraResources. Allow the response to contain ExtraResources kind. Signed-off-by: Michael Fraenkel --- fn.go | 21 +++- fn_test.go | 198 ++++++++++++++++++++++++++++++++- pkg/resource/extraresources.go | 43 +++++++ pkg/resource/res.go | 17 ++- 4 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 pkg/resource/extraresources.go diff --git a/fn.go b/fn.go index 6c4eb68..55a85f7 100644 --- a/fn.go +++ b/fn.go @@ -121,6 +121,18 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } in.Spec.Params["ctx"] = ctxObj + // The extra resources by myself or any previous Functions in the pipeline. + extras, err := request.GetExtraResources(req) + if err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot get extra resources from %T", req)) + return rsp, nil + } + log.Debug(fmt.Sprintf("Extra resources: %d", len(extras))) + in.Spec.Params["extraResources"], err = pkgresource.ObjToRawExtension(extras) + 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 @@ -161,7 +173,8 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) }) } log.Debug(fmt.Sprintf("Input resources: %v", resources)) - result, err := pkgresource.ProcessResources(dxr, oxr, desired, observed, in.Spec.Target, resources, &pkgresource.AddResourcesOptions{ + extraResources := map[string]*fnv1.ResourceSelector{} + result, err := pkgresource.ProcessResources(dxr, oxr, desired, observed, extraResources, in.Spec.Target, resources, &pkgresource.AddResourcesOptions{ Basename: in.Name, Data: data, Overwrite: true, @@ -170,6 +183,12 @@ 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 { + for n, d := range extraResources { + log.Debug(fmt.Sprintf("Requesting ExtraResources from %s named %s", d.String(), n)) + } + rsp.Requirements = &fnv1.Requirements{ExtraResources: extraResources} + } log.Debug(fmt.Sprintf("Set %d resource(s) to the desired state", result.MsgCount)) // Set dxr and desired state log.Debug(fmt.Sprintf("Setting desired XR state to %+v", dxr.Resource)) diff --git a/fn_test.go b/fn_test.go index d68b3dd..28f1565 100644 --- a/fn_test.go +++ b/fn_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/go-logr/logr/testr" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/protobuf/testing/protocmp" @@ -30,6 +31,12 @@ func TestRunFunctionSimple(t *testing.T) { rsp *fnv1.RunFunctionResponse err error } + + var ( + cd = `{"apiVersion":"example.org/v1","kind":"CD","metadata":{"annotations":{"krm.kcl.dev/composition-resource-name":"cool-cd"},"name":"cool-cd"}}` + xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` + ) + cases := map[string]struct { reason string args args @@ -192,6 +199,194 @@ func TestRunFunctionSimple(t *testing.T) { }, }, }, + "ExtraResources": { + reason: "The Function should return the desired composite with extra resources.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "extra-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: \"ExtraResources\"\n requirements = {\n \"cool-extra-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolExtraResource\"\n matchName: \"cool-extra-resource\"\n }\n }\n},\n{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"ExtraResources\"\n requirements = {\n \"another-cool-extra-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolExtraResource\"\n matchLabels = {\n key: \"value\"\n }\n }\n \"yet-another-cool-extra-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolExtraResource\"\n matchName: \"foo\"\n }\n }\n},\n{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"ExtraResources\"\n requirements = {\n \"all-cool-resources\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolExtraResource\"\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: "extra-resources", Ttl: durationpb.New(response.DefaultTTL)}, + Results: []*fnv1.Result{}, + Requirements: &fnv1.Requirements{ + ExtraResources: map[string]*fnv1.ResourceSelector{ + "cool-extra-resource": { + ApiVersion: "example.org/v1", + Kind: "CoolExtraResource", + Match: &fnv1.ResourceSelector_MatchName{ + MatchName: "cool-extra-resource", + }, + }, + "another-cool-extra-resource": { + ApiVersion: "example.org/v1", + Kind: "CoolExtraResource", + Match: &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{ + Labels: map[string]string{"key": "value"}, + }, + }, + }, + "yet-another-cool-extra-resource": { + ApiVersion: "example.org/v1", + Kind: "CoolExtraResource", + Match: &fnv1.ResourceSelector_MatchName{ + MatchName: "foo", + }, + }, + "all-cool-resources": { + ApiVersion: "example.org/v1", + Kind: "CoolExtraResource", + 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), + }, + }, + }, + }, + }, + }, + "ExtraResourcesIn": { + reason: "The Function should return the extra resources from the request.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "extra-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\").extraResources[\"cool1\"]]\n" + } + }`), + ExtraResources: 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: "extra-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"}}`), + }, + }, + }, + }, + }, + }, + "DuplicateExtraResourceKey": { + reason: "The Function should return a fatal result if the extra resource key is duplicated.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "duplicate-extra-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: \"ExtraResources\"\n requirements = {\n \"cool-extra-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolExtraResource\"\n matchName: \"cool-extra-resource\"\n }\n }\n}\n{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"ExtraResources\"\n requirements = {\n \"cool-extra-resource\" = {\n apiVersion: \"example.org/v1\"\n kind: \"CoolExtraResource\"\n matchName: \"another-cool-extra-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-extra-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 extra resource key \"cool-extra-resource\"", + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{ + "cool-cd": { + Resource: resource.MustStructJSON(cd), + }, + }, + }, + }, + }, + }, // TODO: disable the resource check, and fix the kcl dup resource evaluation issues. // "MultipleResourceError": { // reason: "The Function should return a fatal result if input resources have duplicate names", @@ -230,7 +425,8 @@ func TestRunFunctionSimple(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - f := &Function{log: logging.NewNopLogger()} + // f := &Function{log: logging.NewNopLogger()} + f := &Function{log: logging.NewLogrLogger(testr.New(t))} rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { diff --git a/pkg/resource/extraresources.go b/pkg/resource/extraresources.go new file mode 100644 index 0000000..8192468 --- /dev/null +++ b/pkg/resource/extraresources.go @@ -0,0 +1,43 @@ +package resource + +import ( + fnv1 "github.com/crossplane/function-sdk-go/proto/v1" +) + +// ExtraResourcesRequirements defines the requirements for extra resources. +type ExtraResourcesRequirements map[string]ExtraResourcesRequirement + +// ExtraResourcesRequirement defines a single requirement for extra resources. +// Needed to have camelCase keys instead of the snake_case keys as defined +// through json tags by fnv1.ResourceSelector. +type ExtraResourcesRequirement 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 defined, + // matchName is ignored. + MatchLabels map[string]string `json:"matchLabels,omitempty"` + // MatchName defines the name to match the resource, if MatchLabels is + // empty. + MatchName string `json:"matchName,omitempty"` +} + +// ToResourceSelector converts the ExtraResourcesRequirement to a fnv1.ResourceSelector. +func (e *ExtraResourcesRequirement) ToResourceSelector() *fnv1.ResourceSelector { + out := &fnv1.ResourceSelector{ + ApiVersion: e.APIVersion, + Kind: e.Kind, + } + if e.MatchName == "" { + out.Match = &fnv1.ResourceSelector_MatchLabels{ + MatchLabels: &fnv1.MatchLabels{Labels: e.MatchLabels}, + } + return out + } + + out.Match = &fnv1.ResourceSelector_MatchName{ + MatchName: e.MatchName, + } + return out +} diff --git a/pkg/resource/res.go b/pkg/resource/res.go index 3805cd2..8cddbf8 100644 --- a/pkg/resource/res.go +++ b/pkg/resource/res.go @@ -13,6 +13,7 @@ import ( "github.com/crossplane/function-sdk-go/resource" "github.com/crossplane/function-sdk-go/resource/composed" + fnv1 "github.com/crossplane/function-sdk-go/proto/v1" "github.com/pkg/errors" "gopkg.in/yaml.v2" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -386,7 +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, 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, target Target, resources ResourceList, opts *AddResourcesOptions) (AddResourcesResult, error) { result := AddResourcesResult{ Target: target, } @@ -465,8 +466,20 @@ func ProcessResources(dxr *resource.Composite, oxr *resource.Composite, desired d, _ := base64.StdEncoding.DecodeString(v) //nolint:errcheck // k8s returns secret values encoded dxr.ConnectionDetails[k] = d } + case "ExtraResources": + // Set extra resources requirements. + ers := make(ExtraResourcesRequirements) + if err := cd.Resource.GetValueInto("requirements", &ers); err != nil { + return result, errors.Wrap(err, "cannot get extra resources requirements") + } + for k, v := range ers { + if _, found := extraResources[k]; found { + return result, errors.Errorf("duplicate extra resource key %q", k) + } + extraResources[k] = v.ToResourceSelector() + } default: - return result, errors.Errorf("invalid kind %q for apiVersion %q - must be CompositeConnectionDetails", obj.GetKind(), MetaApiVersion) + return result, errors.Errorf("invalid kind %q for apiVersion %q - must be CompositeConnectionDetails or ExtraResources", obj.GetKind(), MetaApiVersion) } continue }