diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cc95a6..d63227a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ env: # The package to push, without a version tag. The default matches GitHub. For # example xpkg.upbound.io/crossplane/function-template-go. - XPKG: xpkg.upbound.io/${{ github.repository}} + XPKG: xpkg.upbound.io/borrelli-org/function-conditional-patch-and-transform # The package version to push. The default is 0.0.0-gitsha. XPKG_VERSION: ${{ inputs.version }} @@ -144,7 +144,7 @@ jobs: run: "curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh" - name: Login to Upbound - uses: docker/login-action@v3 + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3 if: env.XPKG_ACCESS_ID != '' with: registry: xpkg.upbound.io diff --git a/.golangci.yml b/.golangci.yml index 1fd5806..33be29b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -38,7 +38,7 @@ linters-settings: - default - prefix(github.com/crossplane/crossplane-runtime) - prefix(github.com/crossplane/function-sdk-go) - - prefix(github.com/crossplane-contrib/function-patch-and-transform) + - prefix(github.com/upboundcare/function-conditional-patch-and-transform) - blank - dot diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d5ebc8 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ + +DOCKER := docker +GO := go + +GOLANGCI_VERSION := 1.55.2 + +generate: + $(GO) generate ./... + +test: + $(GO) test ./... + +lint: + $(DOCKER) run --rm -v $(CURDIR):/app -v ~/.cache/golangci-lint/v$(GOLANGCI_VERSION):/root/.cache -w /app golangci/golangci-lint:v$(GOLANGCI_VERSION) golangci-lint run --fix + +reviewable: generate test lint + +# run a local process for crossplane render +run-local: + $(GO) run . --debug --insecure \ No newline at end of file diff --git a/README.md b/README.md index 8a41aa7..dd21f92 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,222 @@ -# function-patch-and-transform -[![CI](https://github.com/crossplane-contrib/function-patch-and-transform/actions/workflows/ci.yml/badge.svg)](https://github.com/crossplane-contrib/function-patch-and-transform/actions/workflows/ci.yml) ![GitHub release (latest SemVer)](https://img.shields.io/github/release/crossplane-contrib/function-patch-and-transform) +# function-conditional-patch-and-transform -This [composition function][docs-functions] does everything Crossplane's -built-in [patch & transform][docs-pandt] (P&T) composition does. Instead of -specifying `spec.resources` in your Composition, you can use this function. +[![CI](https://github.com/upboundcare/function-conditional-patch-and-transform/actions/workflows/ci.yml/badge.svg)](https://github.com/upboundcare/function-conditional-patch-and-transform/actions/workflows/ci.yml) ![GitHub release (latest SemVer)](https://img.shields.io/github/release/crossplane-contrib/function-conditional-patch-and-transform) -Using this function, P&T looks like this: +This composition function is a fork of the upstream [function-patch-and-transform](https://github.com/crossplane-contrib/function-patch-and-transform) +that adds support for Conditional invocation of the function and the rendering +of individual resources. + +## Installing this Function + +The function can be installed as a Crossplane package, and runs in a [Composition Function](https://docs.crossplane.io/latest/concepts/composition-functions/). This feature requires a minium Crossplane version of 1.14. ```yaml -apiVersion: apiextensions.crossplane.io/v1 -kind: Composition +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function metadata: - name: example + name: function-conditional-patch-and-transform + annotations: + render.crossplane.io/runtime: Development spec: - # Omitted for brevity. - mode: Pipeline + package: xpkg.upbound.io/upboundcare/function-conditional-patch-and-transform:v0.4.0 +``` + +## What this function does + +This function enables conditional rendering of the entire function or select resources. + +The language used for Conditionals is the [Common Expression Language (CEL)](https://github.com/google/cel-spec), which is widely used in the Kubernetes ecosystem. + +### Conditionally Running the Function + +Composition authors express a CEL condition, and if it returns `true`, patch-and-transforms defined in the `input` will be processed. + +```yaml + mode: Pipeline pipeline: - step: patch-and-transform functionRef: name: function-patch-and-transform input: - apiVersion: pt.fn.crossplane.io/v1beta1 + apiVersion: conditional-pt.fn.crossplane.io/v1beta1 kind: Resources - resources: - - name: bucket - base: - apiVersion: s3.aws.upbound.io/v1beta1 - kind: Bucket - spec: - forProvider: - region: us-east-2 - patches: - - type: FromCompositeFieldPath - fromFieldPath: "spec.location" - toFieldPath: "spec.forProvider.region" - transforms: - - type: map - map: - EU: "eu-north-1" - US: "us-east-2" + condition: observed.composite.resource.spec.env == "prod" && observed.composite.resource.spec.render == true + resources: [...all your resources...] +``` + +Using the following XR and RunFunctionRequest inputs (click to expand): +
+ +```yaml +apiVersion: nopexample.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + env: dev + render: true ``` -Notice that it looks very similar to native P&T. The difference is that -everything is under `spec.pipeline[0].input.resources`, not `spec.resources`. -This is the Function's input. +```json +{ + "desired": { + "composite": { + "resource": { + "apiVersion": "nopexample.org/v1alpha1", + "kind": "XNopResource", + "metadata": { + "name": "test-resource" + }, + "spec": { + "env": "dev", + "render": true + } + } + }, + "resources": { + "test": { + "resource": { + "apiVersion": "example.org/v1", + "kind": "CD", + "metadata": { + "name": "cool-42", + "namespace": "default" + } + } + } + } + }, + "observed": { + "composite": { + "resource": { + "apiVersion": "nopexample.org/v1alpha1", + "kind": "XNopResource", + "metadata": { + "name": "test-resource" + }, + "spec": { + "env": "dev", + "render": true + }, + "status": { + "id": "123", + "ready": false + } + } + } + } +} +``` -## Okay, but why? +
-There are a lot of good reasons to use a function to use a function to do P&T -composition. In fact, it's so compelling that the Crossplane maintainers are -considering deprecating native P&T. See Crossplane issue [#4746] for details. +You can use the [CEL Playground](https://playcel.undistro.io/?content=H4sIAAAAAAAAA%2B1UPW%2FDIBT8K4g5SeW0U9Z27tCh6sDyYl5aVAwIsNUq8n%2BvMY4dGxx16dYNuON93D04Uw4e6IGemSKEMMrRCYuc0QOJR%2F1pqSujnfA4O%2B8hi07XtkyQHgQjXtE6oVWAGVXa4BdURuJO2%2Fe7pgBpPqBgdLO8%2BSkUj3fenrV5GZMkxAo9hB4y%2BXtcQYUxkEfnt1O5c26bBHYGy7WgqJoYk2OT1DTIojjaQPK2xkWuq%2B24ngqYNHWp3KGJrNQ3fCA5K%2BY%2B5JuYTHh8yjNuq0%2FmBpRay%2B3DPhdpZDoDJV60PUEt%2FdKphYCrevaLQVVG9dGhbf4H%2B28HO83lwdfJGF9QMShR7O%2FXkgH%2FDpwTSPerVxRdZ6qlm27ETadKMKn74Fjn1BH4lU5EKDJ8d7vxxdH2B6myt7YTBQAA) to test various queries. -### Mix and match P&T with other functions +Here are some example queries on the XR and RunFunctionRequest: -With this function you can use P&T with other functions. For example you can -create a desired resource using the [Go Templating][fn-go-templating] function, -then patch the result using this function. +- `desired.composite.resource.spec.env == "dev"` evaluates to `true` +- `desired.composite.resource.spec.render == true,` evaluates to `true` +- `desired.composite.resource.spec.render == false"` evaluates to `false` +- `observed.composite.resource.status.ready == true"` evaluates to `false` +- `size(desired.resources) == 0` evaluates to `false` +- `"test" in desired.resources`evaluates to `true` +- `"bad-resource" in desired.resources` evaluates to `false` -To include results from previous functions, simply provide a `resources` entry -for each and specify a `name` field that matches the name of the resource from -the previous function. Also, do not specify any value for the `base` field of -each resource. +### Conditionally Rendering Managed Resources -It's not just patches either. You can use P&T to derive composite resource -connection details from a resource produced by another function, or use it to -determine whether a resource produced by another function is ready. +In a similar manner, individual Managed Resources can also +be rendered conditionally, see the example at [examples/conditional-resources](examples/conditional-resources/). -### Decouple P&T development from Crossplane core +Each resource can have a `condition`. -When P&T development happens in a function, it's not coupled to the Crossplane -release cycle. The maintainers of this function can cut releases more frequently -to add new features to P&T. +```yaml + resources: + - name: blue-resource + condition: observed.composite.resource.spec.deployment.blue == true + base: + apiVersion: nop.crossplane.io/v1alpha1 + kind: NopResource + spec: + forProvider: +``` -It also becomes easier to fork. You could fork this function, add a new kind of -transform and try it out for a few weeks before sending a PR upstream. Or, if -your new feature is controversial, it's now a lot less work to maintain your own -fork long term. +If this condition is set in the Claim/XR, the resource will be rendered: -### Test P&T locally using the Crossplane CLI +```yaml +apiVersion: nop.example.org/v1alpha1 +kind: XNopConditional +metadata: + name: test-resource +spec: + env: dev + render: true + deployment: + blue: true + green: false + +``` + +### Test this function locally using the Crossplane CLI You can use the Crossplane CLI to run any function locally and see what composed resources it would create. This only works with functions - not native P&T. -For example, using the files in the [example](example) directory: +For example, using the files in the [examples](examples) directory: ```shell -$ crossplane beta render xr.yaml composition.yaml functions.yaml +cd examples/conditional-rendering +crossplane beta render xr.yaml composition.yaml functions.yaml ``` + Produces the following output, showing what resources Crossplane would compose: ```yaml --- -apiVersion: example.crossplane.io/v1 -kind: XR +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource metadata: - name: example-xr + name: test-resource --- -apiVersion: s3.aws.upbound.io/v1beta1 -kind: Bucket +apiVersion: nop.crossplane.io/v1alpha1 +kind: NopResource metadata: annotations: - crossplane.io/composition-resource-name: bucket - generateName: example-xr- + crossplane.io/composition-resource-name: test-resource + generateName: test-resource- labels: - crossplane.io/composite: example-xr + crossplane.io/composite: test-resource ownerReferences: - # Omitted for brevity + - apiVersion: nop.example.org/v1alpha1 + blockOwnerDeletion: true + controller: true + kind: XNopResource + name: test-resource + uid: "" spec: forProvider: - region: us-east-2 + conditionAfter: + - conditionStatus: "True" + conditionType: Ready + time: 5s + connectionDetails: + - name: username + value: fakeuser + - name: password + value: verysecurepassword + - name: endpoint + value: 127.0.0.1 + fields: + arrayField: + - stringField: array + integerField: 42 + objectField: + stringField: object + stringField: string ``` See the [composition functions documentation][docs-functions] to learn how to use `crossplane beta render`. -## Differences from the native implementation - -This function has a few small, intentional breaking changes compared to the -native implementation. - -These fields are now required. This makes P&T configuration less ambiguous: - -* `resources[i].name` -* `resources[i].connectionDetails[i].name` -* `resources[i].connectionDetails[i].type` -* `resources[i].patches[i].transforms[i].string.type` -* `resources[i].patches[i].transforms[i].math.type` - -Also, the `resources[i].patches[i].policy.mergeOptions` field is no longer -supported. The same capability can be achieved by setting -`resources[i].patches[i].policy.toFieldPath` to: -- `MergeObject` - equivalent to - `resources[i].patches[i].policy.mergeOptions.keepMapValues: true` -- `AppendArray` - equivalent to - `resources[i].patches[i].policy.mergeOptions.appendSlice: false` - ## Developing this function This function uses [Go][go], [Docker][docker], and the [Crossplane CLI][cli] to @@ -158,9 +240,9 @@ $ crossplane xpkg build -f package --embed-runtime-image=runtime [docs-composition]: https://docs.crossplane.io/v1.14/getting-started/provider-aws-part-2/#create-a-deployment-template [docs-functions]: https://docs.crossplane.io/v1.14/concepts/composition-functions/ [docs-pandt]: https://docs.crossplane.io/v1.14/concepts/patch-and-transform/ -[fn-go-templating]: https://github.com/crossplane-contrib/function-go-templating +[fn-go-templating]: https://github.com/upboundcare/function-go-templating [#4617]: https://github.com/crossplane/crossplane/issues/4617 [#4746]: https://github.com/crossplane/crossplane/issues/4746 [go]: https://go.dev [docker]: https://www.docker.com -[cli]: https://docs.crossplane.io/latest/cli \ No newline at end of file +[cli]: https://docs.crossplane.io/latest/cli diff --git a/condition.go b/condition.go new file mode 100644 index 0000000..941b46e --- /dev/null +++ b/condition.go @@ -0,0 +1,82 @@ +package main + +import ( + "reflect" + + "github.com/google/cel-go/cel" + + "github.com/crossplane/crossplane-runtime/pkg/errors" + + fnv1beta1 "github.com/crossplane/function-sdk-go/proto/v1beta1" +) + +// NewCELEnvironment sets up the CEL Environment +func NewCELEnvironment() (*cel.Env, error) { + return cel.NewEnv( + cel.Types(&fnv1beta1.State{}), + cel.Variable("observed", cel.ObjectType("apiextensions.fn.proto.v1beta1.State")), + cel.Variable("desired", cel.ObjectType("apiextensions.fn.proto.v1beta1.State")), + ) +} + +// ToCELVars formats a RunFunctionRequest for CEL evaluation +func ToCELVars(req *fnv1beta1.RunFunctionRequest) map[string]any { + vars := make(map[string]any) + vars["desired"] = req.GetDesired() + vars["observed"] = req.GetObserved() + return vars +} + +// EvaluateCondition will evaluate a CEL expression +func EvaluateCondition(expression *string, req *fnv1beta1.RunFunctionRequest) (bool, error) { + if expression == nil { + return false, nil + } + + env, err := NewCELEnvironment() + if err != nil { + return false, errors.Wrap(err, "CEL Env error") + } + + ast, iss := env.Parse(*expression) + if iss.Err() != nil { + return false, errors.Wrap(iss.Err(), "CEL Parse error") + } + + // Type-check the expression for correctness. + checked, iss := env.Check(ast) + // Report semantic errors, if present. + if iss.Err() != nil { + return false, errors.Wrap(iss.Err(), "CEL TypeCheck error") + } + + // Ensure the output type is a bool. + if !reflect.DeepEqual(checked.OutputType(), cel.BoolType) { + return false, errors.Errorf( + "CEL Type error: expression '%s' must return a boolean, got %v instead", + *expression, + checked.OutputType()) + } + + // Plan the program. + program, err := env.Program(checked) + if err != nil { + return false, errors.Wrap(err, "CEL program plan") + } + + // Convert our Function Request into map[string]any for CEL evaluation + vars := ToCELVars(req) + + // Evaluate the program without any additional arguments. + result, _, err := program.Eval(vars) + if err != nil { + return false, errors.Wrap(err, "CEL program Evaluation") + } + + ret, ok := result.Value().(bool) + if !ok { + return false, errors.Wrap(err, "CEL program did not return a bool") + } + + return ret, nil +} diff --git a/condition_test.go b/condition_test.go new file mode 100644 index 0000000..cf13e09 --- /dev/null +++ b/condition_test.go @@ -0,0 +1,363 @@ +package main + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/pkg/errors" + + fnv1beta1 "github.com/crossplane/function-sdk-go/proto/v1beta1" + "github.com/crossplane/function-sdk-go/resource" + + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" +) + +func TestEvaluateCondition(t *testing.T) { + dxr := `{"apiVersion":"nopexample.org/v1alpha1","kind":"XNopResource","metadata":{"name":"test-resource"},"spec":{"env":"dev","render":true}}` + oxr := `{"apiVersion":"nopexample.org/v1alpha1","kind":"XNopResource","metadata":{"name":"test-resource"},"spec":{"env":"dev","render":true},"status":{"id":"123","ready":false} }` + + type args struct { + condition v1beta1.Condition + req *fnv1beta1.RunFunctionRequest + } + type want struct { + ret bool + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "CELParseError": { + args: args{ + condition: strPtr("field = value"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: false, + err: errors.New("CEL Parse error"), + }, + }, + "CELTypeError": { + args: args{ + condition: strPtr("size(desired.resources)"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: false, + err: errors.New("CEL Type error: expression 'size(desired.resources)' must return a boolean, got int instead"), + }, + }, + "KeyError": { + args: args{ + condition: strPtr("badkey"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: false, + err: errors.New("CEL TypeCheck error: ERROR: :1:1: undeclared reference to 'badkey' (in container '')\n | badkey\n | ^"), + }, + }, + "TrueDesired": { + args: args{ + condition: strPtr("desired.composite.resource.spec.env == \"dev\" "), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: true, + err: nil, + }, + }, + "TrueDesiredBool": { + args: args{ + condition: strPtr("desired.composite.resource.spec.render == true"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: true, + err: nil, + }, + }, + "FalseDesiredBool": { + args: args{ + condition: strPtr("desired.composite.resource.spec.render == false"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: false, + err: nil, + }, + }, + "FalseObservedBool": { + args: args{ + condition: strPtr("observed.composite.resource.status.ready == true"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: false, + err: nil, + }, + }, + "FalseLengthResources": { + args: args{ + condition: strPtr("size(desired.resources) == 0"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Resources: map[string]*fnv1beta1.Resource{ + "test": { + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","metadata":{"namespace":"default","name":"cool-42"}}`), + }, + }, + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: false, + err: nil, + }, + }, + "TrueResourceMapKeyExists": { + args: args{ + condition: strPtr("\"test-resource\" in desired.resources"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Resources: map[string]*fnv1beta1.Resource{ + "test-resource": { + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","metadata":{"namespace":"default","name":"cool-42"}}`), + }, + }, + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: true, + err: nil, + }, + }, + "FalseResourceMapKeyExists": { + args: args{ + condition: strPtr("\"bad-resource\" in desired.resources"), + req: &fnv1beta1.RunFunctionRequest{ + Input: resource.MustStructObject(&v1beta1.Resources{ + Resources: []v1beta1.ComposedTemplate{ + { + Name: "cool-resource", + Base: &runtime.RawExtension{Raw: []byte(`{"apiVersion":"example.org/v1","kind":"CD"}`)}, + }, + }, + }), + Observed: &fnv1beta1.State{ + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(oxr), + }, + }, + Desired: &fnv1beta1.State{ + Resources: map[string]*fnv1beta1.Resource{ + "test-resource": { + Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","metadata":{"namespace":"default","name":"cool-42"}}`), + }, + }, + Composite: &fnv1beta1.Resource{ + Resource: resource.MustStructJSON(dxr), + }, + }, + }, + }, + want: want{ + ret: false, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ret, err := EvaluateCondition(tc.args.condition, tc.args.req) + + if diff := cmp.Diff(tc.want.ret, ret); diff != "" { + t.Errorf("%s\nEvaluateCondition(...): -want ret, +got ret:\n%s", tc.reason, diff) + } + + if tc.want.err != nil || err != nil { + if !strings.HasPrefix(err.Error(), tc.want.err.Error()) { + t.Errorf("\nEvaluateCondition(...): -want err, +got err:\n-want (error starts with): %s\n-got: %s", tc.want.err.Error(), err) + } + } + + }) + } +} + +func strPtr(str string) *string { + return &str +} diff --git a/connection.go b/connection.go index abe77bc..fddaf85 100644 --- a/connection.go +++ b/connection.go @@ -9,7 +9,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/pkg/resource" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) // ConnectionDetailsExtractor extracts the connection details of a resource. diff --git a/connection_test.go b/connection_test.go index 4ae6f63..4fa2fd6 100644 --- a/connection_test.go +++ b/connection_test.go @@ -14,7 +14,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/resource/fake" "github.com/crossplane/crossplane-runtime/pkg/test" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) func TestExtractConnectionDetails(t *testing.T) { diff --git a/example/composition.yaml b/example/composition.yaml deleted file mode 100644 index f2b6d4b..0000000 --- a/example/composition.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: apiextensions.crossplane.io/v1 -kind: Composition -metadata: - name: function-patch-and-transform -spec: - compositeTypeRef: - apiVersion: example.crossplane.io/v1 - kind: XR - mode: Pipeline - pipeline: - - step: patch-and-transform - functionRef: - name: function-patch-and-transform - input: - apiVersion: pt.fn.crossplane.io/v1beta1 - kind: Resources - resources: - - name: bucket - base: - apiVersion: s3.aws.upbound.io/v1beta1 - kind: Bucket - patches: - - type: FromCompositeFieldPath - fromFieldPath: "spec.location" - toFieldPath: "spec.forProvider.region" - transforms: - - type: map - map: - EU: "eu-north-1" - US: "us-east-2" \ No newline at end of file diff --git a/example/functions.yaml b/example/functions.yaml deleted file mode 100644 index d8edb63..0000000 --- a/example/functions.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: pkg.crossplane.io/v1beta1 -kind: Function -metadata: - name: function-patch-and-transform -spec: - package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.1.4 diff --git a/example/xr.yaml b/example/xr.yaml deleted file mode 100644 index 0ca5a77..0000000 --- a/example/xr.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Replace this with your XR! -apiVersion: example.crossplane.io/v1 -kind: XR -metadata: - name: example-xr -spec: - location: US diff --git a/examples/conditional-rendering/README.md b/examples/conditional-rendering/README.md new file mode 100644 index 0000000..125f1b3 --- /dev/null +++ b/examples/conditional-rendering/README.md @@ -0,0 +1,99 @@ +# Conditional Execution of Patch-and-Transform + +This is an experimental fork using [The Common Expression Language (CEL)](https://github.com/google/cel-spec) to conditionally running the patch and transform function. + +For example,if you have an XR with the following manifest: + +```yaml +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + env: dev + render: false +``` + +and set a Condition of `observed.composite.resource.spec.render == true`, the function +will not run. + +## Running this example + +- Install Crossplane version 1.14 or newer. See +- Install the nop provider in `kubectl apply -f provider.yaml` +- Install the XRD & Composition in `kubectl apply -f definition.yaml -f composition.yaml` +- Install the Function `kubectl apply -f function.yaml` + +Finally install the xr: `kubectl apply -f xr` + +## Validate the Install + +Validate the Composite is ready: + +```shell +$ kubectl get composite +NAME SYNCED READY COMPOSITION AGE +test-resource True True xnopresources.nop.example.org 2m9s +``` + +Validate the resource has been generated. + +```shell +$ kubectl get nopresource +NAME READY SYNCED AGE +test-resource-mpw5s True True 27m +``` + +## Testing Conditions + +The `RunFunctionRequest` passed to the function looks similar to the following manifest. +This can be used in the [CEL Playground](https://playcel.undistro.io) to test +various conditions. + +Note that the function is passed an empty `desired{}` state as per design. + +```yaml +--- +observed: + composite: + resource: + apiVersion: nop.example.org/v1alpha1 + kind: XNopResource + metadata: + name: test-resource + spec: + env: dev + render: true +desired: {} +input: + apiVersion: pt.fn.crossplane.io/v1beta1 + condition: + expression: observed.composite.resource.spec.env == "dev" && observed.composite.resource.spec.render + == true + kind: Resources + resources: + - base: + apiVersion: nop.crossplane.io/v1alpha1 + kind: NopResource + spec: + forProvider: + conditionAfter: + - conditionStatus: 'True' + conditionType: Ready + time: 5s + connectionDetails: + - name: username + value: fakeuser + - name: password + value: verysecurepassword + - name: endpoint + value: 127.0.0.1 + fields: + arrayField: + - stringField: array + integerField: 42 + objectField: + stringField: object + stringField: string + name: test-resource +``` diff --git a/examples/conditional-rendering/composition.yaml b/examples/conditional-rendering/composition.yaml new file mode 100644 index 0000000..18e9c88 --- /dev/null +++ b/examples/conditional-rendering/composition.yaml @@ -0,0 +1,43 @@ +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.nop.example.org +spec: + compositeTypeRef: + apiVersion: nop.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: conditional-patch-and-transform + functionRef: + name: function-conditional-patch-and-transform + input: + apiVersion: conditional-pt.fn.crossplane.io/v1beta1 + kind: Resources + condition: observed.composite.resource.spec.env == "dev" && observed.composite.resource.spec.render == true + resources: + - name: test-resource + base: + apiVersion: nop.crossplane.io/v1alpha1 + kind: NopResource + spec: + forProvider: + fields: + integerField: 42 + stringField: "string" + objectField: + stringField: "object" + arrayField: + - stringField: "array" + conditionAfter: + - time: 5s + conditionType: Ready + conditionStatus: "True" + connectionDetails: + - name: username + value: fakeuser + - name: password + value: verysecurepassword + - name: endpoint + value: 127.0.0.1 diff --git a/examples/conditional-rendering/definition.yaml b/examples/conditional-rendering/definition.yaml new file mode 100644 index 0000000..6eb29c4 --- /dev/null +++ b/examples/conditional-rendering/definition.yaml @@ -0,0 +1,24 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.nop.example.org +spec: + group: nop.example.org + names: + kind: XNopResource + plural: xnopresources + versions: + - name: v1alpha1 + referenceable: true + served: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + env: + type: string + render: + type: boolean \ No newline at end of file diff --git a/examples/conditional-rendering/functions.yaml b/examples/conditional-rendering/functions.yaml new file mode 100644 index 0000000..121ad68 --- /dev/null +++ b/examples/conditional-rendering/functions.yaml @@ -0,0 +1,7 @@ +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-conditional-patch-and-transform +spec: + package: xpkg.upbound.io/upboundcare/function-conditional-patch-and-transform:v0.4.0 + packagePullPolicy: Always diff --git a/examples/conditional-rendering/provider.yaml b/examples/conditional-rendering/provider.yaml new file mode 100644 index 0000000..1e28f83 --- /dev/null +++ b/examples/conditional-rendering/provider.yaml @@ -0,0 +1,6 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-nop +spec: + package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.2.0 \ No newline at end of file diff --git a/examples/conditional-rendering/xr.yaml b/examples/conditional-rendering/xr.yaml new file mode 100644 index 0000000..a830c36 --- /dev/null +++ b/examples/conditional-rendering/xr.yaml @@ -0,0 +1,7 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + env: dev + render: true \ No newline at end of file diff --git a/examples/conditional-resources/README.md b/examples/conditional-resources/README.md new file mode 100644 index 0000000..00afb2e --- /dev/null +++ b/examples/conditional-resources/README.md @@ -0,0 +1,41 @@ +# Conditional Rendering of Individual Resources + +In this example, we show how each resource in a pipeline step can +be individually rendered based on a condition. We will have a blue and a +green deployment that the user can activate: + +```yaml +apiVersion: nop.example.org/v1alpha1 +kind: XNopConditional +metadata: + name: test-resource +spec: + env: dev + render: true + deployment: + blue: true + green: false +``` + +In our Composition both the `blue-resource` and the `green-resource` have a +`condition` that determines if they will be run. + +```yaml + resources: + - name: blue-resource + condition: observed.composite.resource.spec.deployment.blue == true + base: + apiVersion: nop.crossplane.io/v1alpha1 + kind: NopResource + spec: + forProvider: +``` + +## Running this example + +- Install Crossplane version 1.14 or newer. See +- Install the nop provider in `kubectl apply -f provider.yaml` +- Install the XRD & Composition in `kubectl apply -f definition.yaml -f composition.yaml` +- Install the Function `kubectl apply -f functions.yaml` + +Finally install the xr: `kubectl apply -f xr` diff --git a/examples/conditional-resources/composition.yaml b/examples/conditional-resources/composition.yaml new file mode 100644 index 0000000..7fc75ee --- /dev/null +++ b/examples/conditional-resources/composition.yaml @@ -0,0 +1,70 @@ +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopconditionals.nop.example.org +spec: + compositeTypeRef: + apiVersion: nop.example.org/v1alpha1 + kind: XNopConditional + mode: Pipeline + pipeline: + - step: conditional-patch-and-transform + functionRef: + name: function-conditional-patch-and-transform + input: + apiVersion: conditional-pt.fn.crossplane.io/v1beta1 + kind: Resources + condition: observed.composite.resource.spec.render == true + resources: + - name: blue-resource + condition: observed.composite.resource.spec.deployment.blue == true + base: + apiVersion: nop.crossplane.io/v1alpha1 + kind: NopResource + spec: + forProvider: + fields: + integerField: 42 + stringField: "blue" + objectField: + stringField: "blueObject" + arrayField: + - stringField: "blueArray" + conditionAfter: + - time: 5s + conditionType: Ready + conditionStatus: "True" + connectionDetails: + - name: username + value: fakeuser + - name: password + value: verysecurepassword + - name: endpoint + value: 127.0.0.1 + - name: green-resource + condition: observed.composite.resource.spec.deployment.green == true + base: + apiVersion: nop.crossplane.io/v1alpha1 + kind: NopResource + spec: + forProvider: + fields: + integerField: 42 + stringField: "green" + objectField: + stringField: "greenObject" + arrayField: + - stringField: "greenArray" + conditionAfter: + - time: 5s + conditionType: Ready + conditionStatus: "True" + connectionDetails: + - name: username + value: fakeuser + - name: password + value: verysecurepassword + - name: endpoint + value: 127.0.0.1 + diff --git a/examples/conditional-resources/definition.yaml b/examples/conditional-resources/definition.yaml new file mode 100644 index 0000000..e88d9a6 --- /dev/null +++ b/examples/conditional-resources/definition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopconditionals.nop.example.org +spec: + group: nop.example.org + names: + kind: XNopConditional + plural: xnopconditionals + versions: + - name: v1alpha1 + referenceable: true + served: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + env: + type: string + render: + type: boolean + deployment: + type: object + properties: + blue: + description: Activate the blue resource + type: boolean + green: + description: Activate the green resource + type: boolean \ No newline at end of file diff --git a/examples/conditional-resources/functions.yaml b/examples/conditional-resources/functions.yaml new file mode 100644 index 0000000..121ad68 --- /dev/null +++ b/examples/conditional-resources/functions.yaml @@ -0,0 +1,7 @@ +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-conditional-patch-and-transform +spec: + package: xpkg.upbound.io/upboundcare/function-conditional-patch-and-transform:v0.4.0 + packagePullPolicy: Always diff --git a/examples/conditional-resources/provider.yaml b/examples/conditional-resources/provider.yaml new file mode 100644 index 0000000..1e28f83 --- /dev/null +++ b/examples/conditional-resources/provider.yaml @@ -0,0 +1,6 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-nop +spec: + package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.2.0 \ No newline at end of file diff --git a/examples/conditional-resources/xr.yaml b/examples/conditional-resources/xr.yaml new file mode 100644 index 0000000..360d6a2 --- /dev/null +++ b/examples/conditional-resources/xr.yaml @@ -0,0 +1,10 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XNopConditional +metadata: + name: test-resource +spec: + env: dev + render: true + deployment: + blue: true + green: false \ No newline at end of file diff --git a/fn.go b/fn.go index 6dc9e4b..2dc5ae1 100644 --- a/fn.go +++ b/fn.go @@ -19,9 +19,11 @@ import ( "github.com/crossplane/function-sdk-go/resource/composed" "github.com/crossplane/function-sdk-go/response" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) +const conditionError = "Condition error" + // Function performs patch-and-transform style Composition. type Function struct { fnv1beta1.UnimplementedFunctionRunnerServiceServer @@ -45,6 +47,21 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1beta1.RunFunctionRe return rsp, nil } + // Evaluate any Conditions using the values from the Observed XR + if input.Condition != nil { + // Evaluate the condition to see if we should run + run, err := EvaluateCondition(input.Condition, req) + if err != nil { + response.Fatal(rsp, errors.Wrap(err, conditionError)) + return rsp, nil + } + if !run { + log.Debug("Condition evaluated to false. Skipping run.") + return rsp, nil + } + log.Debug("Condition evaluated to true.") + } + // Our input is an opaque object nested in a Composition, so unfortunately // it won't handle validation for us. if err := ValidateResources(input); err != nil { @@ -140,6 +157,21 @@ func (f *Function) RunFunction(ctx context.Context, req *fnv1beta1.RunFunctionRe dcd := &resource.DesiredComposed{Resource: composed.New()} + if t.Condition != nil { + // Evaluate the condition to see if we should skip this template. + run, err := EvaluateCondition(t.Condition, req) + if err != nil { + log.Info(err.Error()) + response.Fatal(rsp, errors.Wrap(err, conditionError)) + return rsp, nil + } + if !run { + log.Debug("Condition evaluated to false. Skipping template.") + continue + } + log.Debug("Condition evaluated to true.") + } + // If we have a base template, render it into our desired resource. If a // previous Function produced a desired resource with this name we'll // overwrite it. If we don't have a base template we'll try to patch to diff --git a/fn_test.go b/fn_test.go index 0f828b4..875f1ef 100644 --- a/fn_test.go +++ b/fn_test.go @@ -22,7 +22,7 @@ import ( "github.com/crossplane/function-sdk-go/resource" "github.com/crossplane/function-sdk-go/response" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) func TestRunFunction(t *testing.T) { diff --git a/go.mod b/go.mod index 928887b..529a3ef 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/crossplane-contrib/function-patch-and-transform +module github.com/upboundcare/function-conditional-patch-and-transform go 1.21 @@ -8,6 +8,7 @@ require ( github.com/alecthomas/kong v0.8.1 github.com/crossplane/crossplane-runtime v1.15.1 github.com/crossplane/function-sdk-go v0.2.0 + github.com/google/cel-go v0.19.0 github.com/google/go-cmp v0.6.0 github.com/pkg/errors v0.9.1 google.golang.org/protobuf v1.32.0 @@ -20,6 +21,7 @@ require ( require ( dario.cat/mergo v1.0.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -58,6 +60,7 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect @@ -71,6 +74,7 @@ require ( golang.org/x/tools v0.17.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac // indirect google.golang.org/grpc v1.61.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index ce1c171..614244e 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/antchfx/htmlquery v1.2.4 h1:qLteofCMe/KGovBI6SQgmou2QNyedFUW+pE+BpeZ4 github.com/antchfx/htmlquery v1.2.4/go.mod h1:2xO6iu3EVWs7R2JYqBbp8YzG50gj/ofqs5/0VZoDZLc= github.com/antchfx/xpath v1.2.0 h1:mbwv7co+x0RwgeGAOHdrKy89GvHaGvxxBtPK0uF9Zr8= github.com/antchfx/xpath v1.2.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -66,6 +68,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/cel-go v0.19.0 h1:vVgaZoHPBDd1lXCYGQOh5A06L4EtuIfmqQ/qnSXSKiU= +github.com/google/cel-go v0.19.0/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -179,6 +183,8 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -275,6 +281,8 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac h1:OZkkudMUu9LVQMCoRUbI/1p5VCo9BOrlvkqMvWtqa6s= +google.golang.org/genproto/googleapis/api v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:B5xPO//w8qmBDjGReYLpR6UJPnkldGkCSMoH/2vxJeg= google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac h1:nUQEQmH/csSvFECKYRv6HWEyypysidKl2I6Qpsglq/0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA= google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= diff --git a/input/generate.go b/input/generate.go index 551cc2a..187e557 100644 --- a/input/generate.go +++ b/input/generate.go @@ -4,6 +4,7 @@ // NOTE(negz): See the below link for details on what is happening here. // https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module +//go:generate rm -rf package/input //go:generate go run -tags generate sigs.k8s.io/controller-tools/cmd/controller-gen paths=./v1beta1 object crd:crdVersions=v1 output:artifacts:config=../package/input package input diff --git a/input/v1beta1/conditions.go b/input/v1beta1/conditions.go new file mode 100644 index 0000000..47038c5 --- /dev/null +++ b/input/v1beta1/conditions.go @@ -0,0 +1,6 @@ +package v1beta1 + +// Condition defines the condition for rendering. +// Conditions are defined using the Common Expression Language +// For more information refer to https://github.com/google/cel-spec +type Condition *string diff --git a/input/v1beta1/resources.go b/input/v1beta1/resources.go index c86f447..a06c61d 100644 --- a/input/v1beta1/resources.go +++ b/input/v1beta1/resources.go @@ -1,6 +1,6 @@ // Package v1beta1 contains the input type for the P&T Composition Function. // +kubebuilder:object:generate=true -// +groupName=pt.fn.crossplane.io +// +groupName=conditional-pt.fn.crossplane.io // +versionName=v1beta1 package v1beta1 @@ -20,6 +20,9 @@ type Resources struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` + // Condition defines a CEL condition whether this function will render + Condition Condition `json:"condition,omitempty"` + // PatchSets define a named set of patches that may be included by any // resource. PatchSets cannot themselves refer to other PatchSets. // +optional diff --git a/input/v1beta1/resources_common.go b/input/v1beta1/resources_common.go index c1d750b..d5d4b5e 100644 --- a/input/v1beta1/resources_common.go +++ b/input/v1beta1/resources_common.go @@ -59,6 +59,9 @@ type ComposedTemplate struct { // +optional Base *runtime.RawExtension `json:"base,omitempty"` + // Condition defines a CEL condition whether this managed resource will render + Condition Condition `json:"condition,omitempty"` + // Patches to and from the composed resource. // +optional Patches []ComposedPatch `json:"patches,omitempty"` diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index bd97bd7..99aba00 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -78,6 +78,11 @@ func (in *ComposedTemplate) DeepCopyInto(out *ComposedTemplate) { *out = new(runtime.RawExtension) (*in).DeepCopyInto(*out) } + if in.Condition != nil { + in, out := &in.Condition, &out.Condition + *out = new(string) + **out = **in + } if in.Patches != nil { in, out := &in.Patches, &out.Patches *out = make([]ComposedPatch, len(*in)) @@ -460,6 +465,11 @@ func (in *Resources) DeepCopyInto(out *Resources) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Condition != nil { + in, out := &in.Condition, &out.Condition + *out = new(string) + **out = **in + } if in.PatchSets != nil { in, out := &in.PatchSets, &out.PatchSets *out = make([]PatchSet, len(*in)) diff --git a/package/crossplane.yaml b/package/crossplane.yaml index 866d2d3..5df4b7a 100644 --- a/package/crossplane.yaml +++ b/package/crossplane.yaml @@ -2,17 +2,17 @@ apiVersion: meta.pkg.crossplane.io/v1beta1 kind: Function metadata: - name: function-patch-and-transform + name: function-conditional-patch-and-transform annotations: meta.crossplane.io/maintainer: Crossplane Maintainers - meta.crossplane.io/source: github.com/crossplane-contrib/function-patch-and-transform + meta.crossplane.io/source: github.com/upboundcare/function-conditional-patch-and-transform meta.crossplane.io/license: Apache-2.0 - meta.crossplane.io/description: A patch & transform composition function + meta.crossplane.io/description: A patch & transform composition function that supports conditionals meta.crossplane.io/readme: | - This composition function does everything Crossplane's built-in - [patch & transform](https://docs.crossplane.io/latest/concepts/patch-and-transform/) - composition does. Instead of specifying `spec.resources` in your - Composition, you can use this function. See the - [README](https://github.com/crossplane-contrib/function-patch-and-transform) + This composition function is a fork of the upstream [function-patch-and-transform](https://github.com/crossplane-contrib/function-patch-and-transform) + that adds support for Conditional invocation of the function and the rendering + of individual resources. + See the + [README](https://github.com/upboundcare/function-conditional-patch-and-transform) for examples and documentation. spec: {} diff --git a/package/function-conditional-patch-and-transform-9b3ddecdb8ff.xpkg b/package/function-conditional-patch-and-transform-9b3ddecdb8ff.xpkg new file mode 100644 index 0000000..0c805bf Binary files /dev/null and b/package/function-conditional-patch-and-transform-9b3ddecdb8ff.xpkg differ diff --git a/package/input/pt.fn.crossplane.io_resources.yaml b/package/input/conditional-pt.fn.crossplane.io_resources.yaml similarity index 99% rename from package/input/pt.fn.crossplane.io_resources.yaml rename to package/input/conditional-pt.fn.crossplane.io_resources.yaml index 9734fdd..b5d234c 100644 --- a/package/input/pt.fn.crossplane.io_resources.yaml +++ b/package/input/conditional-pt.fn.crossplane.io_resources.yaml @@ -4,9 +4,9 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.14.0 - name: resources.pt.fn.crossplane.io + name: resources.conditional-pt.fn.crossplane.io spec: - group: pt.fn.crossplane.io + group: conditional-pt.fn.crossplane.io names: categories: - crossplane @@ -28,6 +28,10 @@ spec: may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string + condition: + description: Condition defines a CEL condition whether this function will + render + type: string environment: description: |- Environment represents the Composition environment. @@ -736,6 +740,10 @@ spec: type: object x-kubernetes-embedded-resource: true x-kubernetes-preserve-unknown-fields: true + condition: + description: Condition defines a CEL condition whether this managed + resource will render + type: string connectionDetails: description: |- ConnectionDetails lists the propagation secret keys from this composed diff --git a/patches.go b/patches.go index 5e06603..a8458a3 100644 --- a/patches.go +++ b/patches.go @@ -15,7 +15,7 @@ import ( "github.com/crossplane/function-sdk-go/resource/composed" "github.com/crossplane/function-sdk-go/resource/composite" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) const ( diff --git a/patches_test.go b/patches_test.go index bc26cbe..1385ee0 100644 --- a/patches_test.go +++ b/patches_test.go @@ -17,7 +17,7 @@ import ( "github.com/crossplane/function-sdk-go/resource/composed" "github.com/crossplane/function-sdk-go/resource/composite" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) func TestApplyFromFieldPathPatch(t *testing.T) { diff --git a/ready.go b/ready.go index bb0dc8a..59de7ab 100644 --- a/ready.go +++ b/ready.go @@ -8,7 +8,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/fieldpath" "github.com/crossplane/crossplane-runtime/pkg/resource" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) // Error strings diff --git a/ready_test.go b/ready_test.go index 5d3bad8..a7e366a 100644 --- a/ready_test.go +++ b/ready_test.go @@ -14,7 +14,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/composed" "github.com/crossplane/crossplane-runtime/pkg/test" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) var _ ReadinessChecker = ReadinessCheckerFn(IsReady) diff --git a/transforms.go b/transforms.go index c1a20c5..2a5ec4b 100644 --- a/transforms.go +++ b/transforms.go @@ -19,7 +19,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/errors" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) const ( diff --git a/transforms_test.go b/transforms_test.go index be328e1..0ef111e 100644 --- a/transforms_test.go +++ b/transforms_test.go @@ -14,7 +14,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/errors" "github.com/crossplane/crossplane-runtime/pkg/test" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) func TestMapResolve(t *testing.T) { diff --git a/validate.go b/validate.go index c815a5c..f0bbb77 100644 --- a/validate.go +++ b/validate.go @@ -6,7 +6,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) // WrapFieldError wraps the given field.Error adding the given field.Path as root of the Field. diff --git a/validate_test.go b/validate_test.go index 5b46a07..f5078bd 100644 --- a/validate_test.go +++ b/validate_test.go @@ -9,7 +9,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/utils/ptr" - "github.com/crossplane-contrib/function-patch-and-transform/input/v1beta1" + "github.com/upboundcare/function-conditional-patch-and-transform/input/v1beta1" ) func TestValidateReadinessCheck(t *testing.T) {