From bd0c788f3824b85ae0e76b63d5e6598cac0fbd14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Wed, 15 Apr 2026 15:27:46 +0200 Subject: [PATCH 1/2] Initial implementation of CEL health resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonasz Łasut-Balcerzak --- README.md | 49 +++- cel/resolver.go | 89 ++++++ example/{ => basic}/README.md | 0 example/{ => basic}/composition-k8s.yaml | 0 example/{ => basic}/composition.yaml | 0 example/{ => basic}/functions.yaml | 0 example/{ => basic}/observed-k8s.yaml | 0 example/{ => basic}/xr.yaml | 0 example/cel-healthcheck/README.md | 92 +++++++ example/cel-healthcheck/composition.yaml | 48 ++++ example/cel-healthcheck/extra-resources.yaml | 7 + example/cel-healthcheck/functions.yaml | 25 ++ example/cel-healthcheck/observed.yaml | 31 +++ example/cel-healthcheck/xr.yaml | 7 + features/features.go | 29 ++ fn.go | 113 +++++++- fn_test.go | 255 ++++++++++++++++++ go.mod | 13 +- go.sum | 24 +- input/generate.go | 15 ++ input/v1beta1/input.go | 31 +++ input/v1beta1/zz_generated.deepcopy.go | 39 +++ main.go | 9 + .../autoready.fn.crossplane.io_inputs.yaml | 51 ++++ 24 files changed, 900 insertions(+), 27 deletions(-) create mode 100644 cel/resolver.go rename example/{ => basic}/README.md (100%) rename example/{ => basic}/composition-k8s.yaml (100%) rename example/{ => basic}/composition.yaml (100%) rename example/{ => basic}/functions.yaml (100%) rename example/{ => basic}/observed-k8s.yaml (100%) rename example/{ => basic}/xr.yaml (100%) create mode 100644 example/cel-healthcheck/README.md create mode 100644 example/cel-healthcheck/composition.yaml create mode 100644 example/cel-healthcheck/extra-resources.yaml create mode 100644 example/cel-healthcheck/functions.yaml create mode 100644 example/cel-healthcheck/observed.yaml create mode 100644 example/cel-healthcheck/xr.yaml create mode 100644 features/features.go create mode 100644 input/generate.go create mode 100644 input/v1beta1/input.go create mode 100644 input/v1beta1/zz_generated.deepcopy.go create mode 100644 package/input/autoready.fn.crossplane.io_inputs.yaml diff --git a/README.md b/README.md index a6d7ee5..2713640 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,53 @@ This function implements resource-specific health checks for standard Kubernetes For all other resource types (Crossplane managed resources, custom resources, etc.), the function falls back to checking the standard Ready status condition. +## CEL-based health checks (alpha) + +Some resource types — notably Crossplane `Configuration` and `Provider` +packages, Cluster API `Cluster` and many more — do not surface a `Ready` status condition, so the default +fallback never considers them ready. To handle these cases the function +supports user-defined readiness expressions written in +[CEL][cel], gated behind the `CELHealthcheckCustomizations` alpha feature +gate. + +Enable the feature gate when running the function: + +```shell +function-auto-ready --feature-gates=CELHealthcheckCustomizations=true +``` + +Customizations are supplied as a map keyed by `__` +(the group's dots replaced with underscores; the core group is the empty +string). Each value is a CEL expression evaluated against the observed +composed resource, bound to the variable `object`. The expression must +return a boolean — any other result, or an evaluation error, is treated +as not ready. When a customization exists for a given GVK it takes +precedence over the built-in health check. + +The map is read from the function's request context. Point the function +at it with the `celHealthCheckCustomizationFrom` input field, which +accepts a [field path][fieldpath]: + +```yaml +- step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready + input: + apiVersion: autoready.fn.crossplane.io/v1alpha1 + kind: Input + celHealthCheckCustomizationFrom: "[apiextensions.crossplane.io/environment].celHealthCheckCustomizations" +``` + +Any source that populates the function context can supply the map — +typically `function-environment-configs` reading an `EnvironmentConfig`, +but an earlier pipeline step works just as well. See +[`example/cel-healthcheck`](example/cel-healthcheck/) for a runnable +example that checks the `Installed` and `Healthy` conditions on a +Crossplane `Configuration`. + +[cel]: https://github.com/google/cel-spec +[fieldpath]: https://pkg.go.dev/github.com/crossplane/function-sdk-go/request + In this example, the [Go Templating][fn-go-templating] function is used to add a desired composed resource - an Amazon Web Services S3 Bucket. Once Crossplane has created the Bucket, the Auto Ready function will let Crossplane know when it @@ -106,7 +153,7 @@ spec: name: function-auto-ready ``` -See the [example](example) directory for an example you can run locally using +See the [example](example/basic/) directory for an example you can run locally using the Crossplane CLI: ```shell diff --git a/cel/resolver.go b/cel/resolver.go new file mode 100644 index 0000000..66720a0 --- /dev/null +++ b/cel/resolver.go @@ -0,0 +1,89 @@ +package cel + +import ( + "encoding/json" + "reflect" + + "github.com/crossplane/function-sdk-go/resource" + "github.com/google/cel-go/cel" + celtypes "github.com/google/cel-go/common/types" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type Resolver struct { + HealthCheckRegistry map[string]string +} + +const ( + errCelQueryFailedToCompile = "failed to compile query" + errCelQueryReturnTypeNotBool = "celQuery does not return a bool type" + errCelQueryFailedToCreateProgram = "failed to create program from the cel query" + errCelQueryFailedToEvalProgram = "failed to eval the program" + errCelQueryFailedToCreateEnvironment = "cel query failed to create environment" + errCelQueryJSON = "failed to marshal or unmarshal the obj for cel query" +) + +func (r Resolver) GetHealthCheck(gvk schema.GroupVersionKind) (celQuery string, found bool) { + gvkKey := gvk.Group + "_" + gvk.Version + "_" + gvk.Kind + + celQuery, found = r.HealthCheckRegistry[gvkKey] + return +} + +func (r Resolver) HealthDeriveFromCelQuery(celQuery string, obj map[string]any) (ready resource.Ready, err error) { + ready = resource.ReadyUnspecified + + env, err := cel.NewEnv( + cel.Variable("object", cel.AnyType), + ) + if err != nil { + err = errors.Wrap(err, errCelQueryFailedToCreateEnvironment) + return ready, err + } + + ast, iss := env.Compile(celQuery) + if iss.Err() != nil { + err = errors.Wrap(iss.Err(), errCelQueryFailedToCompile) + return ready, err + } + if !reflect.DeepEqual(ast.OutputType(), cel.BoolType) { + err = errors.Wrap(err, errCelQueryReturnTypeNotBool) + return ready, err + } + + program, err := env.Program(ast) + if err != nil { + err = errors.Wrap(err, errCelQueryFailedToCreateProgram) + return ready, err + } + + data, err := json.Marshal(obj) + if err != nil { + err = errors.Wrap(err, errCelQueryJSON) + return ready, err + } + + objMap := map[string]any{} + err = json.Unmarshal(data, &objMap) + if err != nil { + // this should not happen, but just in case + err = errors.Wrap(err, errCelQueryJSON) + return ready, err + } + + val, _, err := program.Eval(map[string]any{ + "object": objMap, + }) + if err != nil { + err = errors.Wrap(err, errCelQueryFailedToEvalProgram) + return ready, err + } + + if val == celtypes.True { + ready = resource.ReadyTrue + } else { + ready = resource.ReadyFalse + } + return ready, err +} diff --git a/example/README.md b/example/basic/README.md similarity index 100% rename from example/README.md rename to example/basic/README.md diff --git a/example/composition-k8s.yaml b/example/basic/composition-k8s.yaml similarity index 100% rename from example/composition-k8s.yaml rename to example/basic/composition-k8s.yaml diff --git a/example/composition.yaml b/example/basic/composition.yaml similarity index 100% rename from example/composition.yaml rename to example/basic/composition.yaml diff --git a/example/functions.yaml b/example/basic/functions.yaml similarity index 100% rename from example/functions.yaml rename to example/basic/functions.yaml diff --git a/example/observed-k8s.yaml b/example/basic/observed-k8s.yaml similarity index 100% rename from example/observed-k8s.yaml rename to example/basic/observed-k8s.yaml diff --git a/example/xr.yaml b/example/basic/xr.yaml similarity index 100% rename from example/xr.yaml rename to example/basic/xr.yaml diff --git a/example/cel-healthcheck/README.md b/example/cel-healthcheck/README.md new file mode 100644 index 0000000..c18ce62 --- /dev/null +++ b/example/cel-healthcheck/README.md @@ -0,0 +1,92 @@ +# Example: CEL-based readiness checks for custom resources + +This example shows how to configure `function-auto-ready` to evaluate readiness for +resource types it does not have a built-in health check for, by supplying a +[CEL][cel] expression per GVK. + +By default, `function-auto-ready` falls back to checking the standard +`Ready` status condition for any resource type it does not recognize. Some +resources — like Crossplane `Configuration` packages — never surface a +`Ready` condition, and instead report their state via `Installed` and +`Healthy` conditions. For these resources you can provide a CEL expression +that evaluates the observed object and returns a boolean. + +## How it works + +The composition has three pipeline steps: + +1. **`create-k8s-resources`** (`function-go-templating`) — renders a + `pkg.crossplane.io/v1` `Configuration` as a composed resource. +2. **`fetch-cel-healthcheck-customizations`** (`function-environment-configs`) — + loads the `healthcheck-customizations` `EnvironmentConfig` and merges its + `data` into the composition environment under the + `apiextensions.crossplane.io/environment` context key. +3. **`automatically-detect-ready-composed-resources`** (`function-auto-ready`) — + reads CEL customizations from the environment via + `celHealthCheckCustomizationFrom` and uses them when evaluating readiness. + +### The CEL customization map + +`extra-resources.yaml` defines the customizations keyed by +`__` (the group's dots replaced with underscores; the +core group is the empty string): + +```yaml +data: + celHealthCheckCustomizations: + pkg.crossplane.io_v1_Configuration: > + object.status.conditions.exists(c, c.type == "Installed" && c.status == "True") + && object.status.conditions.exists(c, c.type == "Healthy" && c.status == "True") +``` + +The CEL expression is evaluated against the observed composed resource, which +is bound to the variable `object`. The expression must return a boolean; any +other result, or an evaluation error, is treated as not ready. + +### Wiring it into the function + +`composition.yaml` points the function at the environment key that holds the +map: + +```yaml +- step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready + input: + apiVersion: autoready.fn.crossplane.io/v1alpha1 + kind: Input + celHealthCheckCustomizationFrom: "[apiextensions.crossplane.io/environment].celHealthCheckCustomizations" +``` + +The value of `celHealthCheckCustomizationFrom` is a +[field path][fieldpath] into the function's request context. Any source that +populates that context (environment configs, an earlier function, etc.) can +supply the map. + +## Running the example + +In a separate shell run the local process of function-auto-ready from root of the repository: +```shell +go run . --insecure --feature-gates=CELHealthcheckCustomizations=true +``` + +Run render command from example's directory: +```shell +crossplane render \ + --extra-resources extra-resources.yaml \ + --observed-resources observed.yaml \ + --include-context \ + xr.yaml composition.yaml functions.yaml +``` + +`observed.yaml` simulates a `Configuration` that already exists in the cluster +with both `Installed=True` and `Healthy=True`. Because the supplied CEL +expression evaluates to `true` against that observed state, `function-auto-ready` +marks the composed resource — and therefore the XR — ready. + +To see the fallback behavior, edit `observed.yaml` to flip one of the +conditions to `False` and re-run; the XR should no longer be reported as +ready. + +[cel]: https://github.com/google/cel-spec +[fieldpath]: https://pkg.go.dev/github.com/crossplane/function-sdk-go/request diff --git a/example/cel-healthcheck/composition.yaml b/example/cel-healthcheck/composition.yaml new file mode 100644 index 0000000..4b3d6f8 --- /dev/null +++ b/example/cel-healthcheck/composition.yaml @@ -0,0 +1,48 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: example-k8s-service-cluster-configurations +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: create-k8s-resources + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: pkg.crossplane.io/v1 + kind: Configuration + metadata: + name: configuration-database-platform + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: configuration-database-platform + spec: + ignoreCrossplaneConstraints: false + package: xpkg.crossplane.io/configuration-database-platform:1.0.0 + - step: fetch-cel-healthcheck-customizations + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: healthcheck-customizations + # This function will automatically detect readiness using resource-specific health checks + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready + input: + apiVersion: autoready.fn.crossplane.io/v1beta1 + kind: Input + celHealthCheckCustomizationFrom: "[apiextensions.crossplane.io/environment].celHealthCheckCustomizations" diff --git a/example/cel-healthcheck/extra-resources.yaml b/example/cel-healthcheck/extra-resources.yaml new file mode 100644 index 0000000..fff36e4 --- /dev/null +++ b/example/cel-healthcheck/extra-resources.yaml @@ -0,0 +1,7 @@ +apiVersion: apiextensions.crossplane.io/v1beta1 +kind: EnvironmentConfig +metadata: + name: healthcheck-customizations +data: + celHealthCheckCustomizations: + pkg.crossplane.io_v1_Configuration: 'object.status.conditions.exists(c, c.type == "Installed" && c.status == "True") && object.status.conditions.exists(c, c.type == "Healthy" && c.status == "True")' diff --git a/example/cel-healthcheck/functions.yaml b/example/cel-healthcheck/functions.yaml new file mode 100644 index 0000000..0c11dec --- /dev/null +++ b/example/cel-healthcheck/functions.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-environment-configs +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-environment-configs:v0.6.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-go-templating +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.9.2 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-auto-ready + annotations: + # This tells crossplane beta render to connect to the function locally. + render.crossplane.io/runtime: Development +spec: + # This is ignored when using the Development runtime. + package: function-auto-ready diff --git a/example/cel-healthcheck/observed.yaml b/example/cel-healthcheck/observed.yaml new file mode 100644 index 0000000..9d1d3d5 --- /dev/null +++ b/example/cel-healthcheck/observed.yaml @@ -0,0 +1,31 @@ +# This file simulates observed resources as they would appear in a running cluster +# Use this with: crossplane render xr.yaml composition.yaml functions.yaml -o observed.yaml +--- +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: configuration-database-platform + annotations: + crossplane.io/composition-resource-name: configuration-database-platform +spec: + ignoreCrossplaneConstraints: false + package: xpkg.crossplane.io/configuration-database-platform:1.0.0 + packagePullPolicy: Always + revisionActivationPolicy: Automatic + revisionHistoryLimit: 1 + skipDependencyResolution: false +status: + conditions: + - lastTransitionTime: '2026-04-08T13:18:16Z' + observedGeneration: 13 + reason: HealthyPackageRevision + status: 'True' + type: Healthy + - lastTransitionTime: '2026-04-08T13:18:12Z' + observedGeneration: 13 + reason: ActivePackageRevision + status: 'True' + type: Installed + currentIdentifier: xpkg.crossplane.io/configuration-database-platform:1.0.0 + currentRevision: configuration-database-platform-a75c2c6ed34f + resolvedPackage: xpkg.crossplane.io/configuration-database-platform:1.0.0 diff --git a/example/cel-healthcheck/xr.yaml b/example/cel-healthcheck/xr.yaml new file mode 100644 index 0000000..92bcccd --- /dev/null +++ b/example/cel-healthcheck/xr.yaml @@ -0,0 +1,7 @@ +# Replace this with your XR! +apiVersion: example.crossplane.io/v1 +kind: XR +metadata: + name: example-xr +spec: + region: us-east-2 diff --git a/features/features.go b/features/features.go new file mode 100644 index 0000000..a3da965 --- /dev/null +++ b/features/features.go @@ -0,0 +1,29 @@ +package features + +import ( + "k8s.io/component-base/featuregate" +) + +const ( + // CELHealthcheckCustomizations enables the capability to define healthchecks as CEL queries. + // CEL-based healthchecks overwrite the built-in Go-based healthchecks if defined for one of the supported types. + CELHealthcheckCustomizations featuregate.Feature = "CELHealthcheckCustomizations" +) + +// defaultAutoReadyFeatureGates consists of all known function-auto-ready specific feature keys. +// To add a new feature, define a Feature constant above and add it here with +// its default state and maturity stage (Alpha, Beta, or GA). +var defaultAutoReadyFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ + CELHealthcheckCustomizations: {Default: false, PreRelease: featuregate.Alpha}, +} + +// FeatureGate is the shared global MutableFeatureGate for function-auto-ready. +// It is populated at init time and can be configured via the --feature-gates +// command-line flag in the controller binary. +var FeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate() + +func init() { + if err := FeatureGate.Add(defaultAutoReadyFeatureGates); err != nil { + panic(err) + } +} diff --git a/fn.go b/fn.go index de0ae7a..0a37b0a 100644 --- a/fn.go +++ b/fn.go @@ -2,6 +2,8 @@ package main import ( "context" + "fmt" + "regexp" "time" corev1 "k8s.io/api/core/v1" @@ -15,7 +17,10 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/crossplane/function-auto-ready/cel" + "github.com/crossplane/function-auto-ready/features" "github.com/crossplane/function-auto-ready/healthchecks" + input "github.com/crossplane/function-auto-ready/input/v1beta1" ) // Function returns whatever response you ask it to. @@ -57,7 +62,56 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) f.log.Debug("Found desired resources", "count", len(desired)) - // First, mark standard Kubernetes resources as ready using resource-specific health checks + // Read function input + in := &input.Input{} + if err := request.GetInput(req, in); err != nil { + response.Fatal(rsp, errors.Wrap(err, "invalid input")) + return rsp, nil + } + + // First mark resources based on CEL customizations if CELHealthcheckCustomizations alpha feature is enabled + if features.FeatureGate.Enabled(features.CELHealthcheckCustomizations) { + // Read CEL HealthCheck library from referenced context + if in.CELHealthCheckCustomizationFrom == nil { + response.Fatal(rsp, errors.New("input with celHealthCheckCustomizationFrom is required when using CELHealthcheckCustomizations")) + return rsp, nil + } + + celResolver := cel.Resolver{ + HealthCheckRegistry: GetNestedMap(req.GetContext().AsMap(), *in.CELHealthCheckCustomizationFrom), + } + + for name, dr := range desired { + log := log.WithValues("composed-resource-name", name) + + // Skip if resource doesn't exist yet + or, ok := observed[name] + if !ok { + continue + } + + // Skip if readiness already explicitly set + if dr.Ready != resource.ReadyUnspecified { + continue + } + + // Check if this resource type has a registered health check customization + gvk := or.Resource.GroupVersionKind() + + if celQuery, found := celResolver.GetHealthCheck(gvk); found { + log.Debug("Using resource-specific health check customization", "gvk", gvk.String()) + ready, err := celResolver.HealthDeriveFromCelQuery(celQuery, or.Resource.Object) + if err != nil { + log.Debug(fmt.Sprintf("Encountered error during resource-specific health check customization evaluation: %s", err.Error()), "gvk", gvk.String()) + continue + } + + dr.Ready = ready + } + } + } + + // Second, mark standard Kubernetes resources as ready using resource-specific health checks for name, dr := range desired { log := log.WithValues("composed-resource-name", name) @@ -85,7 +139,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) } } - // Second, check remaining resources using the Ready status condition + // Third, check remaining resources using the Ready status condition for name, dr := range desired { log := log.WithValues("composed-resource-name", name) @@ -126,3 +180,58 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } + +func GetNestedMap(context map[string]any, key string) map[string]string { + parts, err := ParseNestedKey(key) + if err != nil { + return nil + } + + currentValue := any(context) + for _, k := range parts { + // Check if the current value is a map + if nestedMap, ok := currentValue.(map[string]any); ok { + // Get the next value in the nested map + if nextValue, exists := nestedMap[k]; exists { + currentValue = nextValue + } else { + return nil + } + } else { + return nil + } + } + + // Convert the final value to a map[string]string + if resultAny, ok := currentValue.(map[string]any); ok { + result := make(map[string]string) + for k, vAny := range resultAny { + v, ok := vAny.(string) + if ok { + result[k] = v + } + } + return result + } + return nil +} + +// ParseNestedKey enables the bracket and dot notation to key reference +func ParseNestedKey(key string) ([]string, error) { + var parts []string + // Regular expression to extract keys, supporting both dot and bracket notation + regex := regexp.MustCompile(`\[([^\[\]]+)\]|([^.\[\]]+)`) + matches := regex.FindAllStringSubmatch(key, -1) + for _, match := range matches { + if match[1] != "" { + parts = append(parts, match[1]) // Bracket notation + } else if match[2] != "" { + parts = append(parts, match[2]) // Dot notation + } + } + + if len(parts) == 0 { + return nil, errors.New("invalid key") + } + return parts, nil +} diff --git a/fn_test.go b/fn_test.go index 9340bf2..bcdab99 100644 --- a/fn_test.go +++ b/fn_test.go @@ -9,6 +9,7 @@ import ( "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/durationpb" + "github.com/crossplane/function-auto-ready/features" "github.com/crossplane/function-sdk-go/logging" fnv1 "github.com/crossplane/function-sdk-go/proto/v1" "github.com/crossplane/function-sdk-go/resource" @@ -314,3 +315,257 @@ func TestRunFunction(t *testing.T) { }) } } + +func TestCELHealthcheckCustomizations(t *testing.T) { + reqContext := resource.MustStructJSON(`{ + "apiextensions.crossplane.io/environment": { + "celHealthCheckCustomizations": { + "pkg.crossplane.io_v1_Configuration": "object.status.conditions.exists(c, c.type == 'Installed' && c.status == 'True') && object.status.conditions.exists(c, c.type == 'Healthy' && c.status == 'True')" + } + } + }`) + + input := resource.MustStructJSON(`{ + "apiVersion": "autoready.fn.crossplane.io/v1alpha1", + "kind": "Input", + "celHealthCheckCustomizationFrom": "[apiextensions.crossplane.io/environment].celHealthCheckCustomizations" + }`) + + type args struct { + ctx context.Context + req *fnv1.RunFunctionRequest + } + type want struct { + rsp *fnv1.RunFunctionResponse + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "FatalIfInputInvalid": { + reason: "A Fatal result should be returned if Input is missing celHealthCheckCustomizationFrom", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "autoready.fn.crossplane.io/v1alpha1", + "kind": "Input" + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1", + "kind": "TestXR", + "metadata": { + "name": "my-test-xr" + } + }`), + }, + }, + Desired: &fnv1.State{ + Resources: map[string]*fnv1.Resource{ + // This function doesn't care about the desired + // resource schema. In practice it would match + // observed (without status), but for this test it + // doesn't matter. + "my-resource": { + Resource: resource.MustStructJSON(`{}`), + }, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Desired: &fnv1.State{ + Resources: map[string]*fnv1.Resource{ + // This function doesn't care about the desired + // resource schema. In practice it would match + // observed (without status), but for this test it + // doesn't matter. + "my-resource": { + Resource: resource.MustStructJSON(`{}`), + }, + }, + }, + Results: []*fnv1.Result{ + { + Severity: fnv1.Severity_SEVERITY_FATAL, + Message: "input with celHealthCheckCustomizationFrom is required when using CELHealthcheckCustomizations", + Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + }, + }, + }, + "CELHealthCheck": { + reason: "A Configuration should be ready when both conditions are true and CEL customization is present", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Context: reqContext, + Input: input, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1", + "kind": "TestXR", + "metadata": { + "name": "my-test-xr" + } + }`), + }, + Resources: map[string]*fnv1.Resource{ + "my-configuration": { + Resource: resource.MustStructJSON(`{ + "apiVersion": "pkg.crossplane.io/v1", + "kind": "Configuration", + "metadata": { + "name": "my-configuration" + }, + "spec": { + "package": "xpkg.crossplane.io/test-package:0.0.1" + }, + "status": { + "conditions": [ + { + "type": "Installed", + "status": "True" + }, + { + "type": "Healthy", + "status": "True" + } + ] + } + }`), + }, + }, + }, + Desired: &fnv1.State{ + Resources: map[string]*fnv1.Resource{ + // This function doesn't care about the desired + // resource schema. In practice it would match + // observed (without status), but for this test it + // doesn't matter. + "my-configuration": { + Resource: resource.MustStructJSON(`{}`), + }, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Context: reqContext, + Desired: &fnv1.State{ + Resources: map[string]*fnv1.Resource{ + // This function doesn't care about the desired + // resource schema. In practice it would match + // observed (without status), but for this test it + // doesn't matter. + "my-configuration": { + Resource: resource.MustStructJSON(`{}`), + Ready: fnv1.Ready_READY_TRUE, + }, + }, + }, + }, + }, + }, + "FallbackToReadyCondition": { + reason: "Resources without registered health checks should fall back to Ready condition check", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Context: reqContext, + Input: input, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1", + "kind": "TestXR", + "metadata": { + "name": "my-test-xr" + } + }`), + }, + Resources: map[string]*fnv1.Resource{ + "managed-resource": { + Resource: resource.MustStructJSON(`{ + "apiVersion": "rds.aws.crossplane.io/v1alpha1", + "kind": "DBInstance", + "metadata": { + "name": "my-db" + }, + "spec": {}, + "status": { + "conditions": [ + { + "type": "Ready", + "status": "True" + } + ] + } + }`), + }, + }, + }, + Desired: &fnv1.State{ + Resources: map[string]*fnv1.Resource{ + // This function doesn't care about the desired + // resource schema. In practice it would match + // observed (without status), but for this test it + // doesn't matter. + "managed-resource": { + Resource: resource.MustStructJSON(`{}`), + }, + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Context: reqContext, + Desired: &fnv1.State{ + Resources: map[string]*fnv1.Resource{ + // This function doesn't care about the desired + // resource schema. In practice it would match + // observed (without status), but for this test it + // doesn't matter. + "managed-resource": { + Resource: resource.MustStructJSON(`{}`), + Ready: fnv1.Ready_READY_TRUE, + }, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _ = features.FeatureGate.SetFromMap(map[string]bool{ + string(features.CELHealthcheckCustomizations): true, + }) + + f := &Function{log: logging.NewNopLogger(), TTL: response.DefaultTTL} + rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) + + if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/go.mod b/go.mod index 86f6361..0836ec8 100644 --- a/go.mod +++ b/go.mod @@ -6,15 +6,22 @@ require ( github.com/alecthomas/kong v1.15.0 github.com/crossplane/crossplane-runtime/v2 v2.2.1 github.com/crossplane/function-sdk-go v0.6.2 + github.com/google/cel-go v0.27.0 github.com/google/go-cmp v0.7.0 + github.com/pkg/errors v0.9.1 google.golang.org/protobuf v1.36.11 k8s.io/api v0.35.4 k8s.io/apimachinery v0.35.4 + k8s.io/component-base v0.35.0 + sigs.k8s.io/controller-tools v0.20.0 ) require ( + cel.dev/expr v0.25.1 // indirect dario.cat/mergo v1.0.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect @@ -50,7 +57,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect @@ -59,10 +65,13 @@ require ( github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect @@ -72,6 +81,7 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.41.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/grpc v1.79.3 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -85,7 +95,6 @@ require ( k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect sigs.k8s.io/controller-runtime v0.23.1 // indirect - sigs.k8s.io/controller-tools v0.20.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect diff --git a/go.sum b/go.sum index 59c3040..2404917 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= -github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= @@ -23,12 +21,8 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/crossplane/crossplane-runtime/v2 v2.2.0 h1:jLoQm9D5buk9lBqwRtQ40ueaFotjOljJATq+24bVYI8= -github.com/crossplane/crossplane-runtime/v2 v2.2.0/go.mod h1:8I+x4w5bG4x8aO8ifF/QC8GZoNCN6v21NHzgoYPNYAQ= github.com/crossplane/crossplane-runtime/v2 v2.2.1 h1:CJXV8+1SDXLYJx67sUO4MIuLCkKEOKxCS2zg02nBqUI= github.com/crossplane/crossplane-runtime/v2 v2.2.1/go.mod h1:3Xq18YLf2en0BB2OZpcixTKazeX7bS3txLbQHjOR52c= -github.com/crossplane/function-sdk-go v0.6.0 h1:4lRs6diDrlp+HdxnYtpSKp0aHEOQjO4FSng3//mbGuM= -github.com/crossplane/function-sdk-go v0.6.0/go.mod h1:iyLf7eBOQ7FIWkoCIXLWCyAivLd03Vyk54G//hkpkRo= github.com/crossplane/function-sdk-go v0.6.2 h1:B87cWCEqvkfuXVRpqxaqunJWfmfyk9qBZcFjrz0/jS8= github.com/crossplane/function-sdk-go v0.6.2/go.mod h1:TZ7gGzZkbxV1p1KuWQxigLiZ//xRAyrfb2xrMnH2OPc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -195,8 +189,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= @@ -246,8 +240,6 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -266,22 +258,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= -k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= -k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= -k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= -k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= -k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= -k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= -k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= -k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= diff --git a/input/generate.go b/input/generate.go new file mode 100644 index 0000000..551821d --- /dev/null +++ b/input/generate.go @@ -0,0 +1,15 @@ +//go:build generate +// +build generate + +// 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 + +// Remove existing and generate new input manifests +//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 + +import ( + _ "sigs.k8s.io/controller-tools/cmd/controller-gen" //nolint:typecheck +) diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go new file mode 100644 index 0000000..5232f05 --- /dev/null +++ b/input/v1beta1/input.go @@ -0,0 +1,31 @@ +// Package v1beta1 contains the input type for this Function +// +kubebuilder:object:generate=true +// +groupName=autoready.fn.crossplane.io +// +versionName=v1beta1 +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// This isn't a custom resource, in the sense that we never install its CRD. +// It is a KRM-like object, so we generate a CRD to describe its schema. + +// Input is used to provide inputs to this Function. +// +kubebuilder:object:root=true +// +kubebuilder:storageversion +// +kubebuilder:resource:categories=crossplane +type Input struct { + metav1.TypeMeta `json:",inline"` + + metav1.ObjectMeta `json:"metadata,omitempty"` + + // TTL for which a response can be cached in time.Duration format + // +kubebuilder:default="1m0s" + // +optional + TTL string `json:"ttl"` + + // CELHealthCheckCustomizationFrom is a reference to fetch CEL health check customizations from context + // +kubebuilder:validation:Optional + CELHealthCheckCustomizationFrom *string `json:"celHealthCheckCustomizationFrom,omitempty"` +} diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 0000000..37ece64 --- /dev/null +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,39 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Input) DeepCopyInto(out *Input) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.CELHealthCheckCustomizationFrom != nil { + in, out := &in.CELHealthCheckCustomizationFrom, &out.CELHealthCheckCustomizationFrom + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Input. +func (in *Input) DeepCopy() *Input { + if in == nil { + return nil + } + out := new(Input) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Input) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/main.go b/main.go index e3f9c63..49e4e54 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "github.com/alecthomas/kong" + "github.com/crossplane/function-auto-ready/features" "github.com/crossplane/function-sdk-go" "github.com/crossplane/function-sdk-go/response" ) @@ -20,6 +21,8 @@ type CLI struct { Insecure bool `help:"Run without mTLS credentials. If you supply this flag --tls-server-certs-dir will be ignored."` MaxRecvMessageSize int `help:"Maximum size of received messages in MB." default:"4"` TTL *time.Duration `help:"Time to live for function response."` + + FeatureGates string `default:"" help:"Feature gates to enable/disable (e.g. CELHealthcheckCustomizations=true)."` } // Run this Function. @@ -33,6 +36,12 @@ func (c *CLI) Run() error { ttl = *c.TTL } + if c.FeatureGates != "" { + if err := features.FeatureGate.Set(c.FeatureGates); err != nil { + return err + } + } + return function.Serve(&Function{log: log, TTL: ttl}, function.Listen(c.Network, c.Address), function.MTLSCertificates(c.TLSCertsDir), diff --git a/package/input/autoready.fn.crossplane.io_inputs.yaml b/package/input/autoready.fn.crossplane.io_inputs.yaml new file mode 100644 index 0000000..051f33a --- /dev/null +++ b/package/input/autoready.fn.crossplane.io_inputs.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: inputs.autoready.fn.crossplane.io +spec: + group: autoready.fn.crossplane.io + names: + categories: + - crossplane + kind: Input + listKind: InputList + plural: inputs + singular: input + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: Input is used to provide inputs to this Function. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + celHealthCheckCustomizationFrom: + description: CELHealthCheckCustomizationFrom is a reference to fetch CEL + health check customizations from context + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + ttl: + default: 1m0s + description: TTL for which a response can be cached in time.Duration format + type: string + type: object + served: true + storage: true From b6ebb6f8e7cda7452c3cbfdbbc924b5892974d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Fri, 24 Apr 2026 09:29:42 +0200 Subject: [PATCH 2/2] CEL rules directly in input, no Fatal on empty input, resolver reflect->IsExactType and Warning response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonasz Łasut-Balcerzak --- README.md | 46 +++++- cel/resolver.go | 25 +-- fn.go | 21 ++- fn_test.go | 149 +++++++++++++++--- input/v1beta1/input.go | 10 +- input/v1beta1/zz_generated.deepcopy.go | 11 ++ .../autoready.fn.crossplane.io_inputs.yaml | 13 +- 7 files changed, 211 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 2713640..7c9d30c 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,36 @@ Customizations are supplied as a map keyed by `__` string). Each value is a CEL expression evaluated against the observed composed resource, bound to the variable `object`. The expression must return a boolean — any other result, or an evaluation error, is treated -as not ready. When a customization exists for a given GVK it takes -precedence over the built-in health check. +as not ready and surfaces a Warning on the response. When a customization +exists for a given GVK it takes precedence over the built-in health check. -The map is read from the function's request context. Point the function -at it with the `celHealthCheckCustomizationFrom` input field, which -accepts a [field path][fieldpath]: +There are two ways to supply customizations, and they can be combined: + +### Inline CEL rules (`celHealthCheckCustomization`) + +Define rules directly in the function input. This is the simplest option +when the rules are static and do not need to vary across environments: + +```yaml +- step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready + input: + apiVersion: autoready.fn.crossplane.io/v1alpha1 + kind: Input + celHealthCheckCustomization: + pkg.crossplane.io_v1_Configuration: >- + object.status.conditions.exists(c, c.type == 'Installed' && c.status == 'True') && + object.status.conditions.exists(c, c.type == 'Healthy' && c.status == 'True') + cluster.cluster.x-k8s.io_v1beta1_Cluster: >- + object.status.conditions.exists(c, c.type == 'Ready' && c.status == 'True') +``` + +### CEL rules from the function context (`celHealthCheckCustomizationFrom`) + +Read the map from the function's request context by providing a +[field path][fieldpath]. This is useful when rules need to vary per +environment or be shared across multiple compositions: ```yaml - step: automatically-detect-ready-composed-resources @@ -106,8 +130,16 @@ accepts a [field path][fieldpath]: Any source that populates the function context can supply the map — typically `function-environment-configs` reading an `EnvironmentConfig`, -but an earlier pipeline step works just as well. See -[`example/cel-healthcheck`](example/cel-healthcheck/) for a runnable +but an earlier pipeline step works just as well. + +### Combining both sources + +Both fields can be set at the same time. Context-provided rules are loaded +first; inline rules are then merged on top of them. Inline entries take +precedence, so they can selectively override context-provided rules for +specific GVKs without replacing the entire map. + +See [`example/cel-healthcheck`](example/cel-healthcheck/) for a runnable example that checks the `Installed` and `Healthy` conditions on a Crossplane `Configuration`. diff --git a/cel/resolver.go b/cel/resolver.go index 66720a0..d3f97e3 100644 --- a/cel/resolver.go +++ b/cel/resolver.go @@ -1,9 +1,6 @@ package cel import ( - "encoding/json" - "reflect" - "github.com/crossplane/function-sdk-go/resource" "github.com/google/cel-go/cel" celtypes "github.com/google/cel-go/common/types" @@ -21,7 +18,6 @@ const ( errCelQueryFailedToCreateProgram = "failed to create program from the cel query" errCelQueryFailedToEvalProgram = "failed to eval the program" errCelQueryFailedToCreateEnvironment = "cel query failed to create environment" - errCelQueryJSON = "failed to marshal or unmarshal the obj for cel query" ) func (r Resolver) GetHealthCheck(gvk schema.GroupVersionKind) (celQuery string, found bool) { @@ -47,8 +43,9 @@ func (r Resolver) HealthDeriveFromCelQuery(celQuery string, obj map[string]any) err = errors.Wrap(iss.Err(), errCelQueryFailedToCompile) return ready, err } - if !reflect.DeepEqual(ast.OutputType(), cel.BoolType) { - err = errors.Wrap(err, errCelQueryReturnTypeNotBool) + + if !ast.OutputType().IsExactType(cel.BoolType) { + err = errors.New(errCelQueryReturnTypeNotBool) return ready, err } @@ -58,22 +55,8 @@ func (r Resolver) HealthDeriveFromCelQuery(celQuery string, obj map[string]any) return ready, err } - data, err := json.Marshal(obj) - if err != nil { - err = errors.Wrap(err, errCelQueryJSON) - return ready, err - } - - objMap := map[string]any{} - err = json.Unmarshal(data, &objMap) - if err != nil { - // this should not happen, but just in case - err = errors.Wrap(err, errCelQueryJSON) - return ready, err - } - val, _, err := program.Eval(map[string]any{ - "object": objMap, + "object": obj, }) if err != nil { err = errors.Wrap(err, errCelQueryFailedToEvalProgram) diff --git a/fn.go b/fn.go index 0a37b0a..54619f8 100644 --- a/fn.go +++ b/fn.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "maps" "regexp" "time" @@ -71,14 +72,23 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) // First mark resources based on CEL customizations if CELHealthcheckCustomizations alpha feature is enabled if features.FeatureGate.Enabled(features.CELHealthcheckCustomizations) { - // Read CEL HealthCheck library from referenced context - if in.CELHealthCheckCustomizationFrom == nil { - response.Fatal(rsp, errors.New("input with celHealthCheckCustomizationFrom is required when using CELHealthcheckCustomizations")) - return rsp, nil + // Evaluate the CEL health checks customizations + // both CELHealthCheckCustomizationFrom and CELHealthCheckCustomization are merged into celHealthChecks + // with inline CELHealthCheckCustomization taking precedence over customization passed via context + celHealthchecks := make(map[string]string) + + if in.CELHealthCheckCustomizationFrom != nil { + // Initialize celHealthchecks with context entries + celHealthchecks = GetNestedMap(req.GetContext().AsMap(), *in.CELHealthCheckCustomizationFrom) + } + + if in.CELHealthCheckCustomization != nil { + // Merge inline cel health checks with existing health checks, overwrite existing entries if they exist + maps.Copy(celHealthchecks, *in.CELHealthCheckCustomization) } celResolver := cel.Resolver{ - HealthCheckRegistry: GetNestedMap(req.GetContext().AsMap(), *in.CELHealthCheckCustomizationFrom), + HealthCheckRegistry: celHealthchecks, } for name, dr := range desired { @@ -102,6 +112,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) log.Debug("Using resource-specific health check customization", "gvk", gvk.String()) ready, err := celResolver.HealthDeriveFromCelQuery(celQuery, or.Resource.Object) if err != nil { + response.Warning(rsp, err) log.Debug(fmt.Sprintf("Encountered error during resource-specific health check customization evaluation: %s", err.Error()), "gvk", gvk.String()) continue } diff --git a/fn_test.go b/fn_test.go index bcdab99..58dea05 100644 --- a/fn_test.go +++ b/fn_test.go @@ -325,12 +325,6 @@ func TestCELHealthcheckCustomizations(t *testing.T) { } }`) - input := resource.MustStructJSON(`{ - "apiVersion": "autoready.fn.crossplane.io/v1alpha1", - "kind": "Input", - "celHealthCheckCustomizationFrom": "[apiextensions.crossplane.io/environment].celHealthCheckCustomizations" - }`) - type args struct { ctx context.Context req *fnv1.RunFunctionRequest @@ -345,14 +339,16 @@ func TestCELHealthcheckCustomizations(t *testing.T) { args args want want }{ - "FatalIfInputInvalid": { - reason: "A Fatal result should be returned if Input is missing celHealthCheckCustomizationFrom", + "CELHealthCheckFromContext": { + reason: "A Configuration should be ready when both conditions are true and CEL customization in context is present", args: args{ req: &fnv1.RunFunctionRequest{ - Meta: &fnv1.RequestMeta{Tag: "hello"}, + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Context: reqContext, Input: resource.MustStructJSON(`{ "apiVersion": "autoready.fn.crossplane.io/v1alpha1", - "kind": "Input" + "kind": "Input", + "celHealthCheckCustomizationFrom": "[apiextensions.crossplane.io/environment].celHealthCheckCustomizations" }`), Observed: &fnv1.State{ Composite: &fnv1.Resource{ @@ -364,6 +360,32 @@ func TestCELHealthcheckCustomizations(t *testing.T) { } }`), }, + Resources: map[string]*fnv1.Resource{ + "my-configuration": { + Resource: resource.MustStructJSON(`{ + "apiVersion": "pkg.crossplane.io/v1", + "kind": "Configuration", + "metadata": { + "name": "my-configuration" + }, + "spec": { + "package": "xpkg.crossplane.io/test-package:0.0.1" + }, + "status": { + "conditions": [ + { + "type": "Installed", + "status": "True" + }, + { + "type": "Healthy", + "status": "True" + } + ] + } + }`), + }, + }, }, Desired: &fnv1.State{ Resources: map[string]*fnv1.Resource{ @@ -371,7 +393,7 @@ func TestCELHealthcheckCustomizations(t *testing.T) { // resource schema. In practice it would match // observed (without status), but for this test it // doesn't matter. - "my-resource": { + "my-configuration": { Resource: resource.MustStructJSON(`{}`), }, }, @@ -380,35 +402,117 @@ func TestCELHealthcheckCustomizations(t *testing.T) { }, want: want{ rsp: &fnv1.RunFunctionResponse{ - Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Context: reqContext, + Desired: &fnv1.State{ + Resources: map[string]*fnv1.Resource{ + // This function doesn't care about the desired + // resource schema. In practice it would match + // observed (without status), but for this test it + // doesn't matter. + "my-configuration": { + Resource: resource.MustStructJSON(`{}`), + Ready: fnv1.Ready_READY_TRUE, + }, + }, + }, + }, + }, + }, + "CELHealthCheckFromInput": { + reason: "A Configuration should be ready when both conditions are true and CEL customization in Input is present", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Input: resource.MustStructJSON(`{ + "apiVersion": "autoready.fn.crossplane.io/v1alpha1", + "kind": "Input", + "celHealthCheckCustomization": { + "pkg.crossplane.io_v1_Configuration": "object.status.conditions.exists(c, c.type == 'Installed' && c.status == 'True') && object.status.conditions.exists(c, c.type == 'Healthy' && c.status == 'True')" + } + }`), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1", + "kind": "TestXR", + "metadata": { + "name": "my-test-xr" + } + }`), + }, + Resources: map[string]*fnv1.Resource{ + "my-configuration": { + Resource: resource.MustStructJSON(`{ + "apiVersion": "pkg.crossplane.io/v1", + "kind": "Configuration", + "metadata": { + "name": "my-configuration" + }, + "spec": { + "package": "xpkg.crossplane.io/test-package:0.0.1" + }, + "status": { + "conditions": [ + { + "type": "Installed", + "status": "True" + }, + { + "type": "Healthy", + "status": "True" + } + ] + } + }`), + }, + }, + }, Desired: &fnv1.State{ Resources: map[string]*fnv1.Resource{ // This function doesn't care about the desired // resource schema. In practice it would match // observed (without status), but for this test it // doesn't matter. - "my-resource": { + "my-configuration": { Resource: resource.MustStructJSON(`{}`), }, }, }, - Results: []*fnv1.Result{ - { - Severity: fnv1.Severity_SEVERITY_FATAL, - Message: "input with celHealthCheckCustomizationFrom is required when using CELHealthcheckCustomizations", - Target: fnv1.Target_TARGET_COMPOSITE.Enum(), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Desired: &fnv1.State{ + Resources: map[string]*fnv1.Resource{ + // This function doesn't care about the desired + // resource schema. In practice it would match + // observed (without status), but for this test it + // doesn't matter. + "my-configuration": { + Resource: resource.MustStructJSON(`{}`), + Ready: fnv1.Ready_READY_TRUE, + }, }, }, }, }, }, - "CELHealthCheck": { - reason: "A Configuration should be ready when both conditions are true and CEL customization is present", + "CELHealthCheckInlineShouldOverrideHealthCheckFromContext": { + reason: "A Configuration should be not ready when inline CEL customization overrides the customization from Context", args: args{ req: &fnv1.RunFunctionRequest{ Meta: &fnv1.RequestMeta{Tag: "hello"}, Context: reqContext, - Input: input, + Input: resource.MustStructJSON(`{ + "apiVersion": "autoready.fn.crossplane.io/v1alpha1", + "kind": "Input", + "celHealthCheckCustomization": { + "pkg.crossplane.io_v1_Configuration": "false" + }, + "celHealthCheckCustomizationFrom": "[apiextensions.crossplane.io/environment].celHealthCheckCustomizations" + }`), Observed: &fnv1.State{ Composite: &fnv1.Resource{ Resource: resource.MustStructJSON(`{ @@ -471,7 +575,7 @@ func TestCELHealthcheckCustomizations(t *testing.T) { // doesn't matter. "my-configuration": { Resource: resource.MustStructJSON(`{}`), - Ready: fnv1.Ready_READY_TRUE, + Ready: fnv1.Ready_READY_FALSE, }, }, }, @@ -484,7 +588,6 @@ func TestCELHealthcheckCustomizations(t *testing.T) { req: &fnv1.RunFunctionRequest{ Meta: &fnv1.RequestMeta{Tag: "hello"}, Context: reqContext, - Input: input, Observed: &fnv1.State{ Composite: &fnv1.Resource{ Resource: resource.MustStructJSON(`{ diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 5232f05..e5ebd51 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -20,10 +20,12 @@ type Input struct { metav1.ObjectMeta `json:"metadata,omitempty"` - // TTL for which a response can be cached in time.Duration format - // +kubebuilder:default="1m0s" - // +optional - TTL string `json:"ttl"` + // CELHealthCheckCustomization is a inline map of CEL health check customizations + // Inline customizations are merged with from Context customizations + // and take precedence over Context-provided healthchecks allowing for granular overwrites + // +kubebuilder:validation:Optional + // +kubebuilder:pruning:PreserveUnknownFields + CELHealthCheckCustomization *map[string]string `json:"celHealthCheckCustomization,omitempty"` // CELHealthCheckCustomizationFrom is a reference to fetch CEL health check customizations from context // +kubebuilder:validation:Optional diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index 37ece64..b1db91f 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -13,6 +13,17 @@ func (in *Input) DeepCopyInto(out *Input) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.CELHealthCheckCustomization != nil { + in, out := &in.CELHealthCheckCustomization, &out.CELHealthCheckCustomization + *out = new(map[string]string) + if **in != nil { + in, out := *in, *out + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + } if in.CELHealthCheckCustomizationFrom != nil { in, out := &in.CELHealthCheckCustomizationFrom, &out.CELHealthCheckCustomizationFrom *out = new(string) diff --git a/package/input/autoready.fn.crossplane.io_inputs.yaml b/package/input/autoready.fn.crossplane.io_inputs.yaml index 051f33a..e53df35 100644 --- a/package/input/autoready.fn.crossplane.io_inputs.yaml +++ b/package/input/autoready.fn.crossplane.io_inputs.yaml @@ -28,6 +28,15 @@ spec: may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string + celHealthCheckCustomization: + additionalProperties: + type: string + description: |- + CELHealthCheckCustomization is a inline map of CEL health check customizations + Inline customizations are merged with from Context customizations + and take precedence over Context-provided healthchecks allowing for granular overwrites + type: object + x-kubernetes-preserve-unknown-fields: true celHealthCheckCustomizationFrom: description: CELHealthCheckCustomizationFrom is a reference to fetch CEL health check customizations from context @@ -42,10 +51,6 @@ spec: type: string metadata: type: object - ttl: - default: 1m0s - description: TTL for which a response can be cached in time.Duration format - type: string type: object served: true storage: true