diff --git a/examples/default/resources/Makefile b/examples/default/resources/Makefile new file mode 100644 index 0000000..f9a7429 --- /dev/null +++ b/examples/default/resources/Makefile @@ -0,0 +1,2 @@ +run: + crossplane beta render xr.yaml composition.yaml functions.yaml -r diff --git a/examples/default/resources/README.md b/examples/default/resources/README.md new file mode 100644 index 0000000..7de91bc --- /dev/null +++ b/examples/default/resources/README.md @@ -0,0 +1,113 @@ +# Example Manifests + +You can run your function locally and test it using `crossplane beta 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 beta render xr.yaml composition.yaml functions.yaml -r +--- +apiVersion: example.crossplane.io/v1beta1 +kind: XR +metadata: + name: example +status: + dummy: cool-status +--- +apiVersion: iam.aws.upbound.io/v1beta1 +kind: AccessKey +metadata: + annotations: + crossplane.io/composition-resource-name: sample-access-key-1 + generateName: example- + labels: + crossplane.io/composite: example + name: sample-access-key-1 + ownerReferences: + - apiVersion: example.crossplane.io/v1beta1 + blockOwnerDeletion: true + controller: true + kind: XR + name: example + uid: "" +spec: + forProvider: + userSelector: + matchLabels: + testing.upbound.io/example-name: test-user-1 + writeConnectionSecretToRef: + name: sample-access-key-secret-1 + namespace: crossplane-system +--- +apiVersion: iam.aws.upbound.io/v1beta1 +kind: User +metadata: + annotations: + crossplane.io/composition-resource-name: test-user-0 + generateName: example- + labels: + crossplane.io/composite: example + dummy: foo + testing.upbound.io/example-name: test-user-0 + name: test-user-0 + ownerReferences: + - apiVersion: example.crossplane.io/v1beta1 + blockOwnerDeletion: true + controller: true + kind: XR + name: example + uid: "" +spec: + forProvider: {} +--- +apiVersion: iam.aws.upbound.io/v1beta1 +kind: AccessKey +metadata: + annotations: + crossplane.io/composition-resource-name: sample-access-key-0 + generateName: example- + labels: + crossplane.io/composite: example + name: sample-access-key-0 + ownerReferences: + - apiVersion: example.crossplane.io/v1beta1 + blockOwnerDeletion: true + controller: true + kind: XR + name: example + uid: "" +spec: + forProvider: + userSelector: + matchLabels: + testing.upbound.io/example-name: test-user-0 + writeConnectionSecretToRef: + name: sample-access-key-secret-0 + namespace: crossplane-system +--- +apiVersion: iam.aws.upbound.io/v1beta1 +kind: User +metadata: + annotations: + crossplane.io/composition-resource-name: test-user-1 + generateName: example- + labels: + crossplane.io/composite: example + dummy: foo + testing.upbound.io/example-name: test-user-1 + name: test-user-1 + ownerReferences: + - apiVersion: example.crossplane.io/v1beta1 + blockOwnerDeletion: true + controller: true + kind: XR + name: example + uid: "" +spec: + forProvider: {} +``` diff --git a/examples/default/resources/composition.yaml b/examples/default/resources/composition.yaml new file mode 100644 index 0000000..8fd48cc --- /dev/null +++ b/examples/default/resources/composition.yaml @@ -0,0 +1,68 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: function-template-go +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: normal + functionRef: + name: kcl-function + input: + apiVersion: krm.kcl.dev/v1alpha1 + kind: KCLRun + metadata: + name: basic + spec: + source: | + import base64 + + oxr = option("params").oxr + count = oxr.spec.count or 1 + ocds = option("params").ocds + dxr = { + **oxr + status.dummy = "cool-status" + } + details = { + apiVersion: "meta.krm.kcl.dev/v1alpha1" + kind: "CompositeConnectionDetails" + if "sample-access-key-0" in ocds: + data: { + username = ocds["sample-access-key-0"].connectionDetails.username + password = ocds["sample-access-key-0"].connectionDetails.password + url = base64.encode("http://www.example.com") + } + else: + data: {} + } + items = sum([[{ + apiVersion: "iam.aws.upbound.io/v1beta1" + kind: "User" + metadata.name = "test-user-{}".format(i) + metadata.labels: { + "testing.upbound.io/example-name" = "test-user-{}".format(i) + if "test-user-{}".format(i) in ocds: + dummy = ocds["test-user-{}".format(i)].Resource.metadata.labels.dummy + else: + dummy = "foo" + } + spec.forProvider: {} + }, { + apiVersion: "iam.aws.upbound.io/v1beta1" + kind: "AccessKey" + metadata.name = "sample-access-key-{}".format(i) + spec.forProvider.userSelector.matchLabels: { + "testing.upbound.io/example-name" = "test-user-{}".format(i) + } + spec.writeConnectionSecretToRef: { + name: "sample-access-key-secret-{}".format(i) + namespace: "crossplane-system" + } + }] for i in range(count)], []) + [ + + dxr + ] diff --git a/examples/default/resources/functions.yaml b/examples/default/resources/functions.yaml new file mode 100644 index 0000000..602bf9b --- /dev/null +++ b/examples/default/resources/functions.yaml @@ -0,0 +1,9 @@ +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: kcl-function + annotations: + # This tells crossplane beta 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/resources/xr.yaml b/examples/default/resources/xr.yaml new file mode 100644 index 0000000..27cae0a --- /dev/null +++ b/examples/default/resources/xr.yaml @@ -0,0 +1,6 @@ +apiVersion: example.crossplane.io/v1beta1 +kind: XR +metadata: + name: example +spec: + count: 2 diff --git a/fn.go b/fn.go index 8c4a0ae..333f502 100644 --- a/fn.go +++ b/fn.go @@ -16,7 +16,6 @@ import ( "github.com/crossplane-contrib/function-kcl/input/v1beta1" pkgresource "github.com/crossplane-contrib/function-kcl/pkg/resource" - "sigs.k8s.io/yaml" ) @@ -38,20 +37,25 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ response.Fatal(rsp, errors.Wrapf(err, "cannot get Function input from %T", req)) return rsp, nil } + // Set default target + if in.Spec.Target == "" { + in.Spec.Target = pkgresource.Default + } + // Set default params + if in.Spec.Params == nil { + in.Spec.Params = make(map[string]runtime.RawExtension) + } if err := in.Validate(); err != nil { response.Fatal(rsp, errors.Wrap(err, "invalid function input")) return rsp, nil } - // The composite resource that actually exists. oxr, err := request.GetObservedCompositeResource(req) if err != nil { response.Fatal(rsp, errors.Wrap(err, "cannot get observed composite resource")) return rsp, nil } - if in.Spec.Params == nil { - in.Spec.Params = make(map[string]runtime.RawExtension) - } + // Set option("params").oxr in.Spec.Params["oxr"], err = pkgresource.UnstructuredToRawExtension(&oxr.Resource.Unstructured) if err != nil { response.Fatal(rsp, err) @@ -70,6 +74,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ response.Fatal(rsp, errors.Wrap(err, "cannot get desired composite resource")) return rsp, nil } + // Set option("params").oxr dxr.Resource.SetAPIVersion(oxr.Resource.GetAPIVersion()) dxr.Resource.SetKind(oxr.Resource.GetKind()) in.Spec.Params["dxr"], err = pkgresource.UnstructuredToRawExtension(&dxr.Resource.Unstructured) @@ -77,7 +82,6 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ response.Fatal(rsp, err) return rsp, nil } - // The composed resources desired by any previous Functions in the pipeline. desired, err := request.GetDesiredComposedResources(req) if err != nil { @@ -103,7 +107,6 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ response.Fatal(rsp, err) return rsp, nil } - // Input Example: https://github.com/kcl-lang/krm-kcl/blob/main/examples/mutation/set-annotations/suite/good.yaml inputBytes, outputBytes := bytes.NewBuffer([]byte{}), bytes.NewBuffer([]byte{}) kclRunBytes, err := yaml.Marshal(in) @@ -148,6 +151,13 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ response.Fatal(rsp, errors.Wrapf(err, "cannot process xr and state with the pipeline output in %T", rsp)) return rsp, nil } + log.Debug(fmt.Sprintf("Set %d resource(s) to the desired state", result.MsgCount)) + for _, msg := range result.Msgs { + rsp.Results = append(rsp.Results, &fnv1beta1.Result{ + Severity: fnv1beta1.Severity_SEVERITY_NORMAL, + Message: msg, + }) + } // Set dxr and desired state log.Debug(fmt.Sprintf("Setting desired XR state to %+v", dxr.Resource)) @@ -162,16 +172,6 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ response.Fatal(rsp, errors.Wrapf(err, "cannot set desired composed resources in %T", rsp)) return rsp, nil } - - log.Debug(fmt.Sprintf("Set %d resource(s) to the desired state", result.MsgCount)) - for _, msg := range result.Msgs { - rsp.Results = append(rsp.Results, &fnv1beta1.Result{ - Severity: fnv1beta1.Severity_SEVERITY_NORMAL, - Message: msg, - }) - } - log.Info("Successfully processed crossplane KCL function resources", "input", in.Name) - return rsp, nil } diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 36eb381..a44b52c 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -27,14 +27,14 @@ type KCLInput struct { Spec RunSpec `json:"spec,omitempty" yaml:"spec,omitempty"` } -func (in KCLInput) Validate() error { +func (in *KCLInput) Validate() error { if in.Spec.Source == "" { return field.Required(field.NewPath("spec.source"), "kcl source cannot be empty") } switch in.Spec.Target { // Allowed targets - case resource.PatchDesired, resource.Resources, resource.XR: + case resource.Default, resource.PatchDesired, resource.Resources, resource.XR: case resource.PatchResources: if len(in.Spec.Resources) == 0 { return field.Required(field.NewPath("spec.Resources"), fmt.Sprintf("%s target requires at least one resource", resource.PatchResources)) @@ -49,7 +49,7 @@ func (in KCLInput) Validate() error { } } default: - return field.Required(field.NewPath("spec.target"), fmt.Sprintf("invalid target: %s", in.Spec.Target)) + in.Spec.Target = resource.Default } return nil diff --git a/pkg/resource/res.go b/pkg/resource/res.go index f20030b..6ff69b8 100644 --- a/pkg/resource/res.go +++ b/pkg/resource/res.go @@ -1,17 +1,23 @@ package resource import ( + "encoding/base64" "encoding/json" "fmt" "sort" "strings" + "dario.cat/mergo" + "github.com/crossplane/crossplane-runtime/pkg/fieldpath" + "github.com/crossplane/crossplane-runtime/pkg/meta" "github.com/crossplane/function-sdk-go/resource" "github.com/crossplane/function-sdk-go/resource/composed" + "github.com/pkg/errors" "gopkg.in/yaml.v2" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + krmyaml "kcl-lang.io/krm-kcl/pkg/yaml" ) @@ -27,6 +33,15 @@ const ( Resources Target = "Resources" // XR targets the existing Observed XR itself XR Target = "XR" + // Default targets the existing all resources including XR, Desired XR, + // spec.Resources and DesiredComposed Resources. + Default Target = "Default" +) + +const ( + AnnotationKeyReady = "krm.kcl.dev/ready" + + MetaApiVersion = "meta.krm.kcl.dev/v1alpha1" ) type ResourceList []Resource @@ -405,6 +420,57 @@ func ProcessResources(dxr *resource.Composite, oxr *resource.Composite, desired // This is because there already may be desired objects result.Object = data result.MsgCount = len(data) + case Default: + for _, obj := range data { + cd := resource.NewDesiredComposed() + cd.Resource.Unstructured = obj + // Patch dxr + if cd.Resource.GetAPIVersion() == oxr.Resource.GetAPIVersion() && cd.Resource.GetKind() == oxr.Resource.GetKind() { + dst := make(map[string]any) + if err := dxr.Resource.GetValueInto("status", &dst); err != nil && !fieldpath.IsNotFound(err) { + return result, errors.Wrap(err, "cannot get desired composite status") + } + src := make(map[string]any) + if err := cd.Resource.GetValueInto("status", &src); err != nil && !fieldpath.IsNotFound(err) { + return result, errors.Wrap(err, "cannot get templated composite status") + } + if err := mergo.Merge(&dst, src, mergo.WithOverride); err != nil { + return result, errors.Wrap(err, "cannot merge desired composite status") + } + if err := fieldpath.Pave(dxr.Resource.Object).SetValue("status", dst); err != nil { + return result, errors.Wrap(err, "cannot set desired composite status") + } + continue + } + // Check the meta resource + if cd.Resource.GetAPIVersion() == MetaApiVersion { + switch obj.GetKind() { + case "CompositeConnectionDetails": + con, _ := cd.Resource.GetStringObject("data") + for k, v := range con { + d, _ := base64.StdEncoding.DecodeString(v) //nolint:errcheck // k8s returns secret values encoded + dxr.ConnectionDetails[k] = d + } + default: + return result, errors.Errorf("invalid kind %q for apiVersion %q - must be CompositeConnectionDetails", obj.GetKind(), MetaApiVersion) + } + continue + } + if v, found := cd.Resource.GetAnnotations()[AnnotationKeyReady]; found { + if v != string(resource.ReadyTrue) && v != string(resource.ReadyUnspecified) && v != string(resource.ReadyFalse) { + return result, errors.Errorf("invalid function input: invalid %q annotation value %q: must be True, False, or Unspecified", AnnotationKeyReady, v) + } + cd.Ready = resource.Ready(v) + // Remove meta annotation. + meta.RemoveAnnotations(cd.Resource, AnnotationKeyReady) + } + // Patch desired with resource meta name. + desired[resource.Name(cd.Resource.GetName())] = cd + } + result.Object = data + result.MsgCount = len(data) + result.setSuccessMsgs() + return result, nil } result.setSuccessMsgs() return result, nil