diff --git a/.gitignore b/.gitignore index 6bcd1ff..fd38ef7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ vendor/ .kclvm .DS_store package/*.xpkg + +.idea +.vscode \ No newline at end of file diff --git a/README.md b/README.md index 81e8c53..b47c3cc 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,8 @@ bar: # Omitted for brevity ``` You can access the retrieved resources in your code like this: +> Note that Crossplane performs an additional reconciliation pass for extra resources. +> Consequently, during the initial execution, these resources may be uninitialized. It is essential to implement checks to handle this scenario. ```yaml apiVersion: krm.kcl.dev/v1alpha1 kind: KCLInput @@ -522,7 +524,8 @@ spec: source: | er = option("params")?.extraResources - name = er?.bar[0]?.Resource?.metadata?.name or "" + if er?.bar: + name = er?.bar[0]?.Resource?.metadata?.name or "" # Omit other logic ``` @@ -544,6 +547,89 @@ spec: items = [dxr] # Omit other resources ``` +### Settings conditions and events + +> This feature requires Crossplane v1.17 or newer. + +You can set conditions and events directly from KCL, either in the composite resource or both the composite and claim resources. +To set one or more conditions, use the following approach: +```yaml +apiVersion: krm.kcl.dev/v1alpha1 +kind: KCLInput +metadata: + annotations: + "krm.kcl.dev/default_ready": "True" + name: basic +spec: + source: | + oxr = option("params").oxr + + dxr = { + **oxr + } + + conditions = { + apiVersion: "meta.krm.kcl.dev/v1alpha1" + kind: "Conditions" + conditions = [ + { + target: "CompositeAndClaim" + force: False + condition = { + type: "DatabaseReady" + status: "False" + reason: "FailedToCreate" + message: "Encountered an error creating the database" + } + } + ] + } + + items = [ + conditions + dxr + ] +``` + +- **target**: Specifies whether the condition should be present in the composite resource or both the composite and claim resources. Possible values are `CompositeAndClaim` and `Composite` +- **force**: Forces the overwrite of existing conditions. If a condition with the same `type` already exists, it will not be overwritten by default. Setting force to `True` will overwrite the first condition. + +You can also set events as follows: +```yaml +apiVersion: krm.kcl.dev/v1alpha1 +kind: KCLInput +metadata: + annotations: + "krm.kcl.dev/default_ready": "True" + name: basic +spec: + source: | + oxr = option("params").oxr + + dxr = { + **oxr + } + + events = { + apiVersion: "meta.krm.kcl.dev/v1alpha1" + kind: "Events" + events = [ + { + target: "CompositeAndClaim" + event = { + type: "Warning" + reason: "ResourceLimitExceeded" + message: "The resource limit has been exceeded" + } + } + ] + } + items = [ + events + dxr + ] +``` + ## Library You can directly use [KCL standard libraries](https://kcl-lang.io/docs/reference/model/overview) such as `regex.match`, `math.log`. diff --git a/examples/default/conditions/Makefile b/examples/default/conditions/Makefile new file mode 100644 index 0000000..8a8c414 --- /dev/null +++ b/examples/default/conditions/Makefile @@ -0,0 +1,2 @@ +run: + crossplane render --verbose xr.yaml composition.yaml functions.yaml -r diff --git a/examples/default/conditions/README.md b/examples/default/conditions/README.md new file mode 100644 index 0000000..380cf1d --- /dev/null +++ b/examples/default/conditions/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 --extra-resources extra_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/conditions/composition.yaml b/examples/default/conditions/composition.yaml new file mode 100644 index 0000000..a277ff5 --- /dev/null +++ b/examples/default/conditions/composition.yaml @@ -0,0 +1,49 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: function-template-go +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" + name: basic + spec: + source: | + oxr = option("params").oxr + + dxr = { + **oxr + } + + conditions = { + apiVersion: "meta.krm.kcl.dev/v1alpha1" + kind: "Conditions" + conditions = [ + { + target: "CompositeAndClaim" + force: False + condition = { + type: "DatabaseReady" + status: "False" + reason: "FailedToCreate" + message: "Encountered an error creating the database" + } + } + ] + + } + items = [ + conditions + dxr + ] diff --git a/examples/default/conditions/functions.yaml b/examples/default/conditions/functions.yaml new file mode 100644 index 0000000..d5679cb --- /dev/null +++ b/examples/default/conditions/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/conditions/xr.yaml b/examples/default/conditions/xr.yaml new file mode 100644 index 0000000..67aa59f --- /dev/null +++ b/examples/default/conditions/xr.yaml @@ -0,0 +1,6 @@ +apiVersion: example.crossplane.io/v1beta1 +kind: XR +metadata: + name: example +spec: + count: 1 diff --git a/examples/default/events/Makefile b/examples/default/events/Makefile new file mode 100644 index 0000000..8a8c414 --- /dev/null +++ b/examples/default/events/Makefile @@ -0,0 +1,2 @@ +run: + crossplane render --verbose xr.yaml composition.yaml functions.yaml -r diff --git a/examples/default/events/README.md b/examples/default/events/README.md new file mode 100644 index 0000000..380cf1d --- /dev/null +++ b/examples/default/events/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 --extra-resources extra_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/events/composition.yaml b/examples/default/events/composition.yaml new file mode 100644 index 0000000..cc81fa1 --- /dev/null +++ b/examples/default/events/composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: function-template-go +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" + name: basic + spec: + source: | + oxr = option("params").oxr + + dxr = { + **oxr + } + + events = { + apiVersion: "meta.krm.kcl.dev/v1alpha1" + kind: "Events" + events = [ + { + target: "CompositeAndClaim" + event = { + type: "Warning" + reason: "ResourceLimitExceeded" + message: "The resource limit has been exceeded" + } + } + ] + } + items = [ + events + dxr + ] diff --git a/examples/default/events/functions.yaml b/examples/default/events/functions.yaml new file mode 100644 index 0000000..d5679cb --- /dev/null +++ b/examples/default/events/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/events/xr.yaml b/examples/default/events/xr.yaml new file mode 100644 index 0000000..67aa59f --- /dev/null +++ b/examples/default/events/xr.yaml @@ -0,0 +1,6 @@ +apiVersion: example.crossplane.io/v1beta1 +kind: XR +metadata: + name: example +spec: + count: 1 diff --git a/fn.go b/fn.go index 873c457..d80e006 100644 --- a/fn.go +++ b/fn.go @@ -181,7 +181,9 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) } log.Debug(fmt.Sprintf("Input resources: %v", resources)) extraResources := map[string]*fnv1.ResourceSelector{} - result, err := pkgresource.ProcessResources(dxr, oxr, desired, observed, extraResources, in.Spec.Target, resources, &pkgresource.AddResourcesOptions{ + var conditions pkgresource.ConditionResources + var events pkgresource.EventResources + result, err := pkgresource.ProcessResources(dxr, oxr, desired, observed, extraResources, &conditions, &events, in.Spec.Target, resources, &pkgresource.AddResourcesOptions{ Basename: in.Name, Data: data, Overwrite: true, @@ -196,6 +198,21 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) } rsp.Requirements = &fnv1.Requirements{ExtraResources: extraResources} } + + if len(conditions) > 0 { + err := pkgresource.SetConditions(rsp, conditions, log) + if err != nil { + return rsp, nil + } + } + + if len(events) > 0 { + err := pkgresource.SetEvents(rsp, events) + if err != nil { + return rsp, nil + } + } + 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 1349893..4adf426 100644 --- a/fn_test.go +++ b/fn_test.go @@ -6,14 +6,14 @@ import ( "path/filepath" "testing" + "github.com/crossplane/crossplane-runtime/pkg/logging" "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" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" - - "github.com/crossplane/crossplane-runtime/pkg/logging" + "k8s.io/utils/ptr" fnv1 "github.com/crossplane/function-sdk-go/proto/v1" "github.com/crossplane/function-sdk-go/resource" @@ -442,6 +442,138 @@ func TestRunFunctionSimple(t *testing.T) { }, }, }, + "SetConditions": { + reason: "The Function should return the conditions from the request.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "set-conditions"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "krm.kcl.dev/v1alpha1", + "kind": "KCLInput", + "metadata": { + "name": "basic" + }, + "spec": { + "target": "Default", + "source": "items = [{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"Conditions\"\n conditions = [{\n target: \"CompositeAndClaim\"\n force: False\n condition = {\n type: \"DatabaseReady\"\n status: \"False\"\n reason: \"FailedToCreate\"\n message: \"Encountered an error creating the database\"\n }\n },{\n target: \"Composite\"\n force: False\n condition = {\n type: \"DatabaseReady\"\n status: \"False\"\n reason: \"FailedToValidate\"\n message: \"Encountered an error during validation\"\n }\n }]\n}]" + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "set-conditions", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "DatabaseReady", + Status: fnv1.Status_STATUS_CONDITION_FALSE, + Reason: "FailedToCreate", + Message: ptr.To("Encountered an error creating the database"), + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + }, + Results: []*fnv1.Result{}, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{"apiVersion":"","kind":""}`), + }, + Resources: map[string]*fnv1.Resource{}, + }, + }, + }, + }, + "OberwriteCondition": { + reason: "The Function should overwrite the first condition with the same target.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "overwrite-conditions"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "krm.kcl.dev/v1alpha1", + "kind": "KCLInput", + "metadata": { + "name": "basic" + }, + "spec": { + "target": "Default", + "source": "items = [{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"Conditions\"\n conditions = [{\n target: \"CompositeAndClaim\"\n force: False\n condition = {\n type: \"DatabaseReady\"\n status: \"False\"\n reason: \"FailedToCreate\"\n message: \"Encountered an error creating the database\"\n }\n },{\n target: \"Composite\"\n force: True\n condition = {\n type: \"DatabaseReady\"\n status: \"False\"\n reason: \"DatabaseValidation\"\n message: \"Encountered an error during validation\"\n }\n }]\n}]" + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "overwrite-conditions", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{ + { + Type: "DatabaseReady", + Status: fnv1.Status_STATUS_CONDITION_FALSE, + Reason: "FailedToCreate", + Message: ptr.To("Encountered an error creating the database"), + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + { + Type: "DatabaseReady", + Status: fnv1.Status_STATUS_CONDITION_FALSE, + Reason: "DatabaseValidation", + Message: ptr.To("Encountered an error during validation"), + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Results: []*fnv1.Result{}, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{"apiVersion":"","kind":""}`), + }, + Resources: map[string]*fnv1.Resource{}, + }, + }, + }, + }, + "SetEvents": { + reason: "The Function should return the events from the request.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "set-conditions"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "krm.kcl.dev/v1alpha1", + "kind": "KCLInput", + "metadata": { + "name": "basic" + }, + "spec": { + "target": "Default", + "source": "items = [{\n apiVersion: \"meta.krm.kcl.dev/v1alpha1\"\n kind: \"Events\"\n events = [{\n target: \"CompositeAndClaim\"\n event = {\n type: \"Warning\"\n reason: \"ResourceLimitExceeded\"\n message: \"The resource limit has been exceeded\"\n }\n },{\n target: \"Composite\"\n event = {\n type: \"Warning\"\n reason: \"ValidationFailed\"\n message: \"The validation failed\"\n }\n }]\n}]" + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "set-conditions", Ttl: durationpb.New(response.DefaultTTL)}, + Conditions: []*fnv1.Condition{}, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_WARNING, + Message: "The resource limit has been exceeded", + Reason: ptr.To("ResourceLimitExceeded"), + Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), + }, + { + Severity: fnv1.Severity_SEVERITY_WARNING, + Message: "The validation failed", + Reason: ptr.To("ValidationFailed"), + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{"apiVersion":"","kind":""}`), + }, + Resources: map[string]*fnv1.Resource{}, + }, + }, + }, + }, // 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", diff --git a/go.mod b/go.mod index fcba5e6..7ad3baf 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( google.golang.org/protobuf v1.36.1 gopkg.in/yaml.v2 v2.4.0 k8s.io/apimachinery v0.32.0 + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 kcl-lang.io/krm-kcl v0.11.0 sigs.k8s.io/controller-tools v0.17.0 sigs.k8s.io/yaml v1.4.0 @@ -205,7 +206,6 @@ require ( k8s.io/component-base v0.32.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect kcl-lang.io/cli v0.11.0 // indirect kcl-lang.io/kcl-go v0.11.0 // indirect kcl-lang.io/kcl-openapi v0.10.0 // indirect diff --git a/package/input/template.fn.crossplane.io_kclinputs.yaml b/package/input/template.fn.crossplane.io_kclinputs.yaml index ad029ac..9fce3c6 100644 --- a/package/input/template.fn.crossplane.io_kclinputs.yaml +++ b/package/input/template.fn.crossplane.io_kclinputs.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.15.0 + controller-gen.kubebuilder.io/version: v0.17.0 name: kclinputs.template.fn.crossplane.io spec: group: template.fn.crossplane.io diff --git a/pkg/resource/conditions.go b/pkg/resource/conditions.go new file mode 100644 index 0000000..59312f9 --- /dev/null +++ b/pkg/resource/conditions.go @@ -0,0 +1,107 @@ +package resource + +import ( + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/logging" + fnv1 "github.com/crossplane/function-sdk-go/proto/v1" + "github.com/crossplane/function-sdk-go/response" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +// Target determines which objects to set the condition on. +type BindingTarget string + +const ( + // TargetComposite targets only the composite resource. + TargetComposite BindingTarget = "Composite" + + // TargetCompositeAndClaim targets both the composite and the claim. + TargetCompositeAndClaim BindingTarget = "CompositeAndClaim" +) + +type ConditionResources []ConditionResource + +// ConditionResource will set a condition on the target. +type ConditionResource struct { + // The target(s) to receive the condition. Can be Composite or + // CompositeAndClaim. Defaults to Composite + Target *BindingTarget `json:"target"` + // If true, the condition will override a condition of the same Type. Defaults + // to false. + Force *bool `json:"force"` + // Condition to set. + Condition Condition `json:"condition"` +} + +// Condition allows you to specify fields to set on a composite resource and +// claim. +type Condition struct { + // Type of the condition. Required. + Type string `json:"type"` + // Status of the condition. Required. + Status metav1.ConditionStatus `json:"status"` + // Reason of the condition. Required. + Reason string `json:"reason"` + // Message of the condition. Optional. A template can be used. The available + // template variables come from capturing groups in MatchCondition message + // regular expressions. + Message *string `json:"message"` +} + +// transformCondition converts a ConditionResource into an fnv1.Condition while mapping status and target accordingly. +func transformCondition(cs ConditionResource) *fnv1.Condition { + c := &fnv1.Condition{ + Type: cs.Condition.Type, + Reason: cs.Condition.Reason, + Target: transformTarget(cs.Target), + } + + switch cs.Condition.Status { + case metav1.ConditionTrue: + c.Status = fnv1.Status_STATUS_CONDITION_TRUE + case metav1.ConditionFalse: + c.Status = fnv1.Status_STATUS_CONDITION_FALSE + case metav1.ConditionUnknown: + fallthrough + default: + c.Status = fnv1.Status_STATUS_CONDITION_UNKNOWN + } + + c.Message = cs.Condition.Message + + return c +} + +func transformTarget(t *BindingTarget) *fnv1.Target { + target := ptr.Deref(t, TargetComposite) + if target == TargetCompositeAndClaim { + return fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum() + } + return fnv1.Target_TARGET_COMPOSITE.Enum() +} + +// SetConditions updates the RunFunctionResponse with specified conditions from ConditionResources, ensuring no duplicates. +// It validates that system-reserved Crossplane condition types are not set and permits forced updates when specified. +func SetConditions(rsp *fnv1.RunFunctionResponse, cr ConditionResources, log logging.Logger) error { + conditionsSet := map[string]bool{} + // All matchConditions matched, set the desired conditions. + for _, cs := range cr { + if xpv1.IsSystemConditionType(xpv1.ConditionType(cs.Condition.Type)) { + response.Fatal(rsp, errors.Errorf("cannot set ClaimCondition type: %s is a reserved Crossplane Condition", cs.Condition.Type)) + return errors.New("error updating response") + } + if conditionsSet[cs.Condition.Type] && (cs.Force == nil || !*cs.Force) { + // The condition is already set and this setter is not forceful. + log.Debug("skipping because condition is already set and setCondition is not forceful") + continue + } + log.Debug("setting condition") + + c := transformCondition(cs) + rsp.Conditions = append(rsp.Conditions, c) + conditionsSet[cs.Condition.Type] = true + } + return nil +} diff --git a/pkg/resource/events.go b/pkg/resource/events.go new file mode 100644 index 0000000..bb0c61d --- /dev/null +++ b/pkg/resource/events.go @@ -0,0 +1,81 @@ +package resource + +import ( + "github.com/crossplane/crossplane-runtime/pkg/errors" + fnv1 "github.com/crossplane/function-sdk-go/proto/v1" + "github.com/crossplane/function-sdk-go/response" + "k8s.io/utils/ptr" +) + +// EventType type of an event. +type EventType string + +const ( + // EventTypeNormal signifies a normal event. + EventTypeNormal EventType = "Normal" + + // EventTypeWarning signifies a warning event. + EventTypeWarning EventType = "Warning" +) + +type EventResources []CreateEvent + +// Event allows you to specify the fields of an event to create. +type Event struct { + // Type of the event. Optional. Should be either Normal or Warning. + Type *EventType `json:"type"` + // Reason of the event. Optional. + Reason *string `json:"reason"` + // Message of the event. Required. A template can be used. The available + // template variables come from capturing groups in MatchCondition message + // regular expressions. + Message string `json:"message"` +} + +// CreateEvent will create an event for the target(s). +type CreateEvent struct { + // The target(s) to create an event for. Can be Composite or + // CompositeAndClaim. Defaults to Composite + Target *BindingTarget `json:"target"` + + // Event to create. + Event Event `json:"event"` +} + +// SetEvents processes a list of EventResources, transforms them into Results, and appends them to the RunFunctionResponse. +// Returns an error if any transformation fails. +func SetEvents(rsp *fnv1.RunFunctionResponse, ers EventResources) error { + for _, er := range ers { + r, err := transformEvent(er) + if err != nil { + response.Fatal(rsp, err) + return errors.New("error updating response") + } + rsp.Results = append(rsp.Results, r) + } + return nil +} + +// transformEvent converts a CreateEvent into a fnv1.Result object, handling event severity, reason, message, and target. +// Returns a fnv1.Result object or an error if the event type is invalid. +func transformEvent(ec CreateEvent) (*fnv1.Result, error) { + e := &fnv1.Result{ + Reason: ec.Event.Reason, + Target: transformTarget(ec.Target), + } + + deref := ptr.Deref(ec.Event.Type, EventTypeNormal) + switch deref { + case EventTypeNormal: + e.Severity = fnv1.Severity_SEVERITY_NORMAL + break + case EventTypeWarning: + e.Severity = fnv1.Severity_SEVERITY_WARNING + break + default: + return &fnv1.Result{}, errors.Errorf("invalid type %s, must be one of [Normal, Warning]", *ec.Event.Type) + } + + e.Message = ec.Event.Message + return e, nil +} diff --git a/pkg/resource/res.go b/pkg/resource/res.go index c4c937d..cd11f81 100644 --- a/pkg/resource/res.go +++ b/pkg/resource/res.go @@ -383,7 +383,8 @@ 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, 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, conditions *ConditionResources, + events *EventResources, target Target, resources ResourceList, opts *AddResourcesOptions) (AddResourcesResult, error) { result := AddResourcesResult{ Target: target, } @@ -474,6 +475,16 @@ func ProcessResources(dxr *resource.Composite, oxr *resource.Composite, desired } extraResources[k] = v.ToResourceSelector() } + case "Conditions": + // Returns conditions to add to the claim / composite + if err := cd.Resource.GetValueInto("conditions", conditions); err != nil { + return result, errors.Wrap(err, "cannot get condition resources") + } + case "Events": + // Returns events to add to the claim / composite + if err := cd.Resource.GetValueInto("events", events); err != nil { + return result, errors.Wrap(err, "cannot get event resources") + } default: return result, errors.Errorf("invalid kind %q for apiVersion %q - must be CompositeConnectionDetails or ExtraResources", obj.GetKind(), MetaApiVersion) }